-
# frozen_string_literal: true
-
-
module Admin
-
module Actions
-
class UserGrantBillingAdminAccessAction < Admin::Base::ActionHandler
-
def call
-
Billing::AdminAccessService.new(user: record, actor: current_user).grant!
-
success("Granted Admin/Developer billing access.")
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Actions
-
class UserRevokeBillingAdminAccessAction < Admin::Base::ActionHandler
-
def call
-
Billing::AdminAccessService.new(user: record, actor: current_user).revoke!
-
success("Revoked Admin/Developer billing access.")
-
end
-
end
-
end
-
end
-
-
-
# frozen_string_literal: true
-
-
require "csv"
-
-
module Admin
-
module Base
-
# Builds export data for admin resources
-
#
-
# Generates JSON or CSV exports based on resource configuration
-
# and the columns defined in the index configuration.
-
#
-
# @example
-
# export_builder = Admin::Base::ExportBuilder.new(CompanyResource, companies)
-
# json_data = export_builder.to_json
-
# csv_data = export_builder.to_csv
-
class ExportBuilder
-
attr_reader :resource_class, :records
-
-
# Initializes the export builder
-
#
-
# @param resource_class [Class] The resource class
-
# @param records [ActiveRecord::Relation, Array] Records to export
-
def initialize(resource_class, records)
-
@resource_class = resource_class
-
@records = records
-
end
-
-
# Exports records to JSON
-
#
-
# @param options [Hash] Export options
-
# @option options [Boolean] :pretty Pretty print JSON
-
# @return [String] JSON string
-
def to_json(options = {})
-
data = records.map { |record| record_to_hash(record) }
-
-
if options[:pretty]
-
JSON.pretty_generate(export_wrapper(data))
-
else
-
export_wrapper(data).to_json
-
end
-
end
-
-
# Exports records to CSV
-
#
-
# @return [String] CSV string
-
def to_csv
-
return "" if records.empty?
-
-
headers = export_columns.map { |col| col[:header] }
-
-
CSV.generate do |csv|
-
csv << headers
-
-
records.each do |record|
-
csv << export_columns.map { |col| column_value(record, col) }
-
end
-
end
-
end
-
-
# Returns the filename for export
-
#
-
# @param format [Symbol] Export format (:json, :csv)
-
# @return [String]
-
def filename(format)
-
timestamp = Time.current.strftime("%Y%m%d_%H%M%S")
-
"#{resource_class.resource_name_plural}_#{timestamp}.#{format}"
-
end
-
-
# Returns content type for export
-
#
-
# @param format [Symbol] Export format
-
# @return [String]
-
def content_type(format)
-
case format.to_sym
-
when :json
-
"application/json"
-
when :csv
-
"text/csv"
-
else
-
"application/octet-stream"
-
end
-
end
-
-
private
-
-
def index_config
-
@resource_class.index_config
-
end
-
-
def model_class
-
@resource_class.model_class
-
end
-
-
def export_wrapper(data)
-
{
-
resource: resource_class.resource_name,
-
exported_at: Time.current.iso8601,
-
count: data.length,
-
records: data
-
}
-
end
-
-
def export_columns
-
return default_columns unless index_config
-
-
columns = index_config.columns_list.map do |col|
-
{
-
name: col.name,
-
header: col.header,
-
content: col.content
-
}
-
end
-
-
# Add id and timestamps if not already present
-
unless columns.any? { |c| c[:name] == :id }
-
columns.unshift({ name: :id, header: "ID", content: nil })
-
end
-
-
unless columns.any? { |c| c[:name] == :created_at }
-
columns << { name: :created_at, header: "Created At", content: nil }
-
end
-
-
columns
-
end
-
-
def default_columns
-
model_class.column_names.map do |col|
-
{ name: col.to_sym, header: col.humanize, content: nil }
-
end
-
end
-
-
def record_to_hash(record)
-
hash = {}
-
-
export_columns.each do |col|
-
hash[col[:name]] = column_value(record, col)
-
end
-
-
hash
-
end
-
-
def column_value(record, col)
-
if col[:content].is_a?(Proc)
-
value = col[:content].call(record)
-
elsif col[:content].is_a?(Symbol)
-
value = record.public_send(col[:content])
-
elsif record.respond_to?(col[:name])
-
value = record.public_send(col[:name])
-
else
-
value = nil
-
end
-
-
# Serialize complex values for export
-
serialize_value(value)
-
end
-
-
def serialize_value(value)
-
case value
-
when ActiveRecord::Base
-
value.id
-
when ActiveRecord::Relation
-
value.pluck(:id)
-
when Time, DateTime
-
value.iso8601
-
when Date
-
value.to_s
-
when Hash, Array
-
value
-
else
-
value.to_s
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Admin
-
module Base
-
# Registry for form field types
-
#
-
# Maps field types to their corresponding view partials and
-
# provides helper methods for rendering fields based on their type.
-
#
-
# @example
-
# registry = Admin::Base::FieldRegistry.new
-
# partial = registry.partial_for(:toggle)
-
# # => "admin/fields/toggle"
-
class FieldRegistry
-
# Default field type to partial mappings
-
FIELD_TYPES = {
-
# Basic types
-
text: { partial: "admin/fields/text", input_type: :text_field },
-
string: { partial: "admin/fields/text", input_type: :text_field },
-
email: { partial: "admin/fields/text", input_type: :email_field },
-
url: { partial: "admin/fields/text", input_type: :url_field },
-
tel: { partial: "admin/fields/text", input_type: :telephone_field },
-
password: { partial: "admin/fields/text", input_type: :password_field },
-
number: { partial: "admin/fields/number", input_type: :number_field },
-
integer: { partial: "admin/fields/number", input_type: :number_field },
-
decimal: { partial: "admin/fields/number", input_type: :number_field },
-
-
# Text areas
-
textarea: { partial: "admin/fields/textarea", input_type: :text_area },
-
text_area: { partial: "admin/fields/textarea", input_type: :text_area },
-
-
# Boolean
-
boolean: { partial: "admin/fields/toggle", input_type: :check_box },
-
toggle: { partial: "admin/fields/toggle", input_type: nil },
-
checkbox: { partial: "admin/fields/checkbox", input_type: :check_box },
-
-
# Date/Time
-
date: { partial: "admin/fields/date", input_type: :date_field },
-
datetime: { partial: "admin/fields/datetime", input_type: :datetime_local_field },
-
time: { partial: "admin/fields/time", input_type: :time_field },
-
date_range: { partial: "admin/fields/date_range", input_type: nil },
-
-
# Select
-
select: { partial: "admin/fields/select", input_type: :select },
-
searchable_select: { partial: "admin/fields/searchable_select", input_type: nil },
-
collection_select: { partial: "admin/fields/collection_select", input_type: :collection_select },
-
-
# Rich content
-
rich_text: { partial: "admin/fields/rich_text", input_type: :rich_text_area },
-
trix: { partial: "admin/fields/rich_text", input_type: :rich_text_area },
-
markdown: { partial: "admin/fields/markdown", input_type: :text_area },
-
-
# File
-
file: { partial: "admin/fields/file", input_type: :file_field },
-
image: { partial: "admin/fields/file", input_type: :file_field },
-
-
# Special
-
json: { partial: "admin/fields/json", input_type: :text_area },
-
color: { partial: "admin/fields/color", input_type: :color_field },
-
hidden: { partial: nil, input_type: :hidden_field },
-
tag_picker: { partial: "admin/fields/tag_picker", input_type: nil },
-
-
# Read-only display
-
readonly: { partial: "admin/fields/readonly", input_type: nil },
-
badge: { partial: "admin/fields/badge", input_type: nil }
-
}.freeze
-
-
class << self
-
# Returns the partial path for a field type
-
#
-
# @param type [Symbol] Field type
-
# @return [String, nil] Partial path or nil
-
def partial_for(type)
-
config = FIELD_TYPES[type.to_sym]
-
config&.dig(:partial)
-
end
-
-
# Returns the input type for a field type
-
#
-
# @param type [Symbol] Field type
-
# @return [Symbol, nil] Form builder input method
-
def input_type_for(type)
-
config = FIELD_TYPES[type.to_sym]
-
config&.dig(:input_type)
-
end
-
-
# Checks if a field type uses a custom partial
-
#
-
# @param type [Symbol] Field type
-
# @return [Boolean]
-
def custom_partial?(type)
-
partial_for(type).present?
-
end
-
-
# Returns all registered field types
-
#
-
# @return [Array<Symbol>]
-
def types
-
FIELD_TYPES.keys
-
end
-
-
# Checks if a field type is valid
-
#
-
# @param type [Symbol] Field type
-
# @return [Boolean]
-
def valid_type?(type)
-
FIELD_TYPES.key?(type.to_sym)
-
end
-
-
# Returns options for rendering a field
-
#
-
# @param field_def [FieldDefinition] Field definition
-
# @return [Hash] Options hash for rendering
-
def options_for(field_def)
-
{
-
form: nil, # Set by caller
-
field: field_def.name,
-
label: field_def.label,
-
help: field_def.help,
-
placeholder: field_def.placeholder,
-
required: field_def.required,
-
readonly: field_def.readonly
-
}.tap do |opts|
-
# Add type-specific options
-
case field_def.type
-
when :select, :collection_select
-
opts[:collection] = field_def.collection
-
when :searchable_select
-
opts[:search_url] = field_def.collection
-
opts[:create_url] = field_def.create_url
-
opts[:creatable] = field_def.create_url.present?
-
when :file, :image
-
opts[:accept] = field_def.accept
-
opts[:preview] = field_def.type == :image
-
when :textarea, :markdown
-
opts[:rows] = field_def.rows || 6
-
end
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Base
-
# Navigation builder for admin portals
-
#
-
# Builds navigation structure from portal and resource definitions.
-
# Used by the sidebar partial to render navigation links.
-
#
-
# @example
-
# navigation = Admin::Base::Navigation.new(current_portal, current_path)
-
# navigation.sections.each do |section|
-
# section.items.each do |item|
-
# # render nav item
-
# end
-
# end
-
class Navigation
-
attr_reader :portal, :current_path
-
-
NavSection = Struct.new(:key, :label, :icon, :items, keyword_init: true)
-
NavItem = Struct.new(:key, :label, :path, :icon, :badge, :active, keyword_init: true)
-
-
# Icon mappings for resources
-
RESOURCE_ICONS = {
-
dashboard: :home,
-
users: :users,
-
email_senders: :mail,
-
connected_accounts: :link,
-
synced_emails: :inbox,
-
blog_posts: :document_text,
-
companies: :building_office,
-
job_roles: :briefcase,
-
job_listings: :clipboard_list,
-
categories: :tag,
-
skill_tags: :hashtag,
-
scraping_metrics: :chart_bar,
-
scraping_attempts: :clock,
-
scraping_events: :bell,
-
html_scraping_logs: :code,
-
support_tickets: :ticket,
-
interview_applications: :document,
-
settings: :cog,
-
assistant_threads: :chat_bubble_left_right,
-
assistant_turns: :arrow_path,
-
assistant_events: :bolt,
-
assistant_tools: :wrench,
-
assistant_tool_executions: :play,
-
assistant_user_memories: :light_bulb,
-
assistant_memory_proposals: :clipboard_check,
-
assistant_thread_summaries: :document_text,
-
llm_prompts: :command_line,
-
llm_provider_configs: :cpu_chip,
-
llm_api_logs: :server
-
}.freeze
-
-
def initialize(portal, current_path)
-
@portal = portal
-
@current_path = current_path
-
end
-
-
# Returns navigation sections for the portal
-
#
-
# @return [Array<NavSection>]
-
def sections
-
return [] unless portal&.sections_list
-
-
portal.sections_list.map do |section|
-
NavSection.new(
-
key: section.key,
-
label: section.display_label,
-
icon: section.section_icon,
-
items: items_for_section(section)
-
)
-
end
-
end
-
-
# Returns the portal switcher data
-
#
-
# @return [Array<Hash>]
-
def portal_switcher
-
Portal.registered_portals.map do |p|
-
{
-
key: p.identifier,
-
name: p.portal_name,
-
icon: p.portal_icon,
-
path: p.portal_path_prefix,
-
active: portal == p
-
}
-
end
-
end
-
-
private
-
-
def items_for_section(section)
-
section.resource_keys.map do |key|
-
build_nav_item(key)
-
end.compact
-
end
-
-
def build_nav_item(key)
-
path = path_for_resource(key)
-
return nil unless path
-
-
NavItem.new(
-
key: key,
-
label: label_for_resource(key),
-
path: path,
-
icon: RESOURCE_ICONS[key] || :folder,
-
badge: badge_for_resource(key),
-
active: current_path&.start_with?(path)
-
)
-
end
-
-
def path_for_resource(key)
-
case portal.identifier
-
when :operations
-
ops_path_for(key)
-
when :ai
-
ai_path_for(key)
-
else
-
"/admin/#{key}"
-
end
-
end
-
-
def ops_path_for(key)
-
case key
-
when :dashboard then "/admin"
-
when :scraping_metrics then "/admin/scraping_metrics"
-
else "/admin/#{key}"
-
end
-
end
-
-
def ai_path_for(key)
-
case key
-
when :dashboard then "/admin/ai"
-
when :llm_prompts then "/admin/ai/llm_prompts"
-
when :llm_api_logs then "/admin/ai/llm_api_logs"
-
else "/admin/#{key}"
-
end
-
end
-
-
def label_for_resource(key)
-
key.to_s.humanize.titleize
-
end
-
-
def badge_for_resource(key)
-
case key
-
when :email_senders
-
count = EmailSender.unassigned.count rescue 0
-
count.positive? ? count : nil
-
when :support_tickets
-
count = SupportTicket.open_tickets.count rescue 0
-
count.positive? ? count : nil
-
when :synced_emails
-
count = SyncedEmail.needs_review.count rescue 0
-
count.positive? ? count : nil
-
else
-
nil
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Base
-
# Base class for admin portals
-
#
-
# A portal is a logical grouping of admin resources with its own
-
# navigation, dashboard, and access controls.
-
#
-
# @example
-
# class Admin::Portals::OpsPortal < Admin::Base::Portal
-
# name "Operations"
-
# icon :building
-
# path_prefix "/admin/ops"
-
#
-
# section :users_email do
-
# label "Users & Email"
-
# icon :users
-
# resources :users, :email_senders, :connected_accounts
-
# end
-
# end
-
class Portal
-
class << self
-
attr_reader :portal_name, :portal_icon, :portal_path_prefix, :sections_list
-
-
# Sets the portal display name
-
#
-
# @param value [String] Portal name
-
# @return [void]
-
def name(value)
-
@portal_name = value
-
end
-
-
# Sets the portal icon
-
#
-
# @param value [Symbol] Icon name
-
# @return [void]
-
def icon(value)
-
@portal_icon = value
-
end
-
-
# Sets the path prefix for this portal
-
#
-
# @param value [String] Path prefix
-
# @return [void]
-
def path_prefix(value)
-
@portal_path_prefix = value
-
end
-
-
# Defines a section within the portal
-
#
-
# @param key [Symbol] Section key
-
# @yield Block for section configuration
-
# @return [void]
-
def section(key, &block)
-
@sections_list ||= []
-
section = SectionConfig.new(key)
-
section.instance_eval(&block) if block_given?
-
@sections_list << section
-
end
-
-
# Returns the portal identifier
-
#
-
# @return [Symbol]
-
def identifier
-
portal_name.to_s.parameterize.underscore.to_sym
-
end
-
-
# Returns resources for this portal
-
#
-
# @return [Array<Class>]
-
def resources
-
Resource.resources_for_portal(identifier)
-
end
-
-
# Returns all registered portals
-
#
-
# @return [Array<Class>]
-
def registered_portals
-
@registered_portals ||= []
-
end
-
-
# Called when a subclass is created
-
def inherited(subclass)
-
super
-
# Use to_s to get class name to avoid conflict with our custom name(value) method
-
class_name = subclass.to_s
-
registered_portals << subclass unless class_name.include?("Base")
-
end
-
-
# Finds a portal by identifier
-
#
-
# @param identifier [Symbol] Portal identifier
-
# @return [Class, nil]
-
def find(identifier)
-
registered_portals.find { |p| p.identifier == identifier.to_sym }
-
end
-
end
-
-
# Section configuration within a portal
-
class SectionConfig
-
attr_reader :key, :section_label, :section_icon, :resource_keys
-
-
def initialize(key)
-
@key = key
-
@resource_keys = []
-
end
-
-
# Sets the section label
-
#
-
# @param value [String] Section label
-
# @return [void]
-
def label(value)
-
@section_label = value
-
end
-
-
# Sets the section icon
-
#
-
# @param value [Symbol] Icon name
-
# @return [void]
-
def icon(value)
-
@section_icon = value
-
end
-
-
# Adds resources to this section
-
#
-
# @param keys [Array<Symbol>] Resource keys
-
# @return [void]
-
def resources(*keys)
-
@resource_keys.concat(keys)
-
end
-
-
# Returns the display label
-
#
-
# @return [String]
-
def display_label
-
section_label || key.to_s.humanize
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Admin
-
module Base
-
# Calculates statistics for admin resource index pages
-
#
-
# Uses the stat definitions from the resource's index configuration
-
# to generate a hash of calculated statistics.
-
#
-
# @example
-
# calculator = Admin::Base::StatsCalculator.new(CompanyResource)
-
# stats = calculator.calculate
-
# # => { total: 150, with_website: 120, with_job_listings: 45 }
-
class StatsCalculator
-
attr_reader :resource_class
-
-
# Initializes the stats calculator
-
#
-
# @param resource_class [Class] The resource class with stat definitions
-
def initialize(resource_class)
-
@resource_class = resource_class
-
end
-
-
# Calculates all defined statistics
-
#
-
# @return [Hash] Hash of stat names to calculated values
-
def calculate
-
return {} unless index_config
-
return {} if index_config.stats_list.empty?
-
-
stats = {}
-
-
index_config.stats_list.each do |stat_def|
-
stats[stat_def.name] = calculate_stat(stat_def)
-
end
-
-
stats
-
end
-
-
# Returns stat colors for display
-
#
-
# @return [Hash] Hash of stat names to color classes
-
def colors
-
return {} unless index_config
-
-
colors = {}
-
-
index_config.stats_list.each do |stat_def|
-
next unless stat_def.color
-
-
colors[stat_def.name] = color_class_for(stat_def.color)
-
end
-
-
colors
-
end
-
-
private
-
-
def index_config
-
@resource_class.index_config
-
end
-
-
def calculate_stat(stat_def)
-
if stat_def.calculator.is_a?(Proc)
-
stat_def.calculator.call
-
elsif stat_def.calculator.is_a?(Symbol)
-
model_class.public_send(stat_def.calculator)
-
else
-
stat_def.calculator
-
end
-
rescue StandardError => e
-
Rails.logger.error "Failed to calculate stat #{stat_def.name}: #{e.message}"
-
"N/A"
-
end
-
-
def model_class
-
@resource_class.model_class
-
end
-
-
def color_class_for(color)
-
case color.to_sym
-
when :blue
-
"text-blue-600 dark:text-blue-400"
-
when :green
-
"text-green-600 dark:text-green-400"
-
when :amber, :yellow
-
"text-amber-600 dark:text-amber-400"
-
when :red
-
"text-red-600 dark:text-red-400"
-
when :purple
-
"text-purple-600 dark:text-purple-400"
-
when :slate, :gray
-
"text-slate-600 dark:text-slate-400"
-
else
-
"text-slate-900 dark:text-white"
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Portals
-
# AI Portal
-
#
-
# Contains resources for AI and Assistant operations including:
-
# - Assistant threads and conversations
-
# - Tool executions and management
-
# - User memories and proposals
-
# - LLM configuration and logs
-
class AiPortal < Admin::Base::Portal
-
name "AI"
-
icon :sparkles
-
path_prefix "/admin/ai"
-
-
section :dashboard do
-
label "Dashboard"
-
icon :chart_bar
-
resources :dashboard
-
end
-
-
section :assistant do
-
label "Assistant"
-
icon :chat
-
resources :assistant_threads, :assistant_turns, :assistant_events,
-
:assistant_tools, :assistant_tool_executions
-
end
-
-
section :memory do
-
label "Memory"
-
icon :brain
-
resources :assistant_user_memories, :assistant_memory_proposals, :assistant_thread_summaries
-
end
-
-
section :llm do
-
label "LLM"
-
icon :cpu
-
resources :llm_prompts, :llm_provider_configs, :llm_api_logs
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Portals
-
# Operations Portal
-
#
-
# Contains resources for day-to-day operations including:
-
# - User management
-
# - Email management
-
# - Content management
-
# - Scraping operations
-
# - Support tickets
-
class OpsPortal < Admin::Base::Portal
-
name "Operations"
-
icon :building
-
path_prefix "/admin/ops"
-
-
section :dashboard do
-
label "Dashboard"
-
icon :home
-
resources :dashboard
-
end
-
-
section :users_email do
-
label "Users & Email"
-
icon :users
-
resources :users, :email_senders, :connected_accounts, :synced_emails
-
end
-
-
section :content do
-
label "Content"
-
icon :document
-
resources :blog_posts, :companies, :job_roles, :job_listings, :categories, :skill_tags
-
end
-
-
section :scraping do
-
label "Scraping"
-
icon :code
-
resources :scraping_metrics, :scraping_attempts, :scraping_events, :html_scraping_logs
-
end
-
-
section :support do
-
label "Support"
-
icon :chat
-
resources :support_tickets, :interview_applications
-
end
-
-
section :system do
-
label "System"
-
icon :cog
-
resources :settings
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for Assistant Event admin management
-
#
-
# Provides read-only access to assistant events for debugging.
-
class AssistantEventResource < Admin::Base::Resource
-
model ::Assistant::Ops::Event
-
portal :assistant
-
section :events
-
-
index do
-
sortable :created_at, default: :created_at
-
paginate 50
-
-
stats do
-
stat :total, -> { ::Assistant::Ops::Event.count }
-
stat :info, -> { ::Assistant::Ops::Event.where(severity: "info").count }, color: :blue
-
stat :warn, -> { ::Assistant::Ops::Event.where(severity: "warn").count }, color: :amber
-
stat :error, -> { ::Assistant::Ops::Event.where(severity: "error").count }, color: :red
-
stat :last_24h, -> { ::Assistant::Ops::Event.where("created_at >= ?", 24.hours.ago).count }
-
end
-
-
columns do
-
column :event_type, header: "Event"
-
column :severity, type: :label, label_color: ->(e) {
-
case e.severity.to_sym
-
when :info then :blue
-
when :warn then :amber
-
when :error then :red
-
else :gray
-
end
-
}
-
column :thread, ->(e) { e.thread&.display_title&.truncate(25) }
-
column :trace_id, ->(e) { e.trace_id&.truncate(12) }
-
column :created_at, ->(e) { e.created_at.strftime("%b %d, %H:%M:%S") }
-
end
-
-
filters do
-
filter :event_type, type: :text, placeholder: "Event type..."
-
filter :severity, type: :select, options: [
-
[ "All", "" ],
-
[ "Info", "info" ],
-
[ "Warning", "warn" ],
-
[ "Error", "error" ]
-
]
-
filter :trace_id, type: :text, placeholder: "Trace ID..."
-
filter :thread_id, type: :number, label: "Thread ID"
-
end
-
end
-
-
show do
-
sidebar do
-
panel :event, title: "Event", fields: [ :event_type, :severity ]
-
panel :ids, title: "Identifiers", fields: [ :trace_id ]
-
panel :timestamps, title: "Timestamps", fields: [ :created_at ]
-
end
-
-
main do
-
panel :thread, title: "Thread", fields: [ :thread ]
-
panel :payload, title: "Payload", fields: [ :payload ]
-
end
-
end
-
-
exportable :json
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for Assistant Memory Proposal admin management
-
#
-
# Provides read-only access to memory proposals for debugging.
-
class AssistantMemoryProposalResource < Admin::Base::Resource
-
model ::Assistant::Memory::MemoryProposal
-
portal :assistant
-
section :memory
-
-
index do
-
sortable :created_at, default: :created_at
-
paginate 30
-
-
stats do
-
stat :total, -> { ::Assistant::Memory::MemoryProposal.count }
-
stat :pending, -> { ::Assistant::Memory::MemoryProposal.where(status: "pending").count }, color: :amber
-
stat :accepted, -> { ::Assistant::Memory::MemoryProposal.where(status: "accepted").count }, color: :green
-
stat :rejected, -> { ::Assistant::Memory::MemoryProposal.where(status: "rejected").count }, color: :red
-
stat :last_24h, -> { ::Assistant::Memory::MemoryProposal.where("created_at >= ?", 24.hours.ago).count }, color: :blue
-
end
-
-
columns do
-
column :user, ->(mp) { mp.user&.email_address }
-
column :status, type: :label, label_color: ->(mp) {
-
case mp.status.to_sym
-
when :pending then :amber
-
when :accepted then :green
-
when :rejected then :red
-
else :gray
-
end
-
}
-
column :items_count, ->(mp) { mp.proposed_items&.size || 0 }, header: "Items"
-
column :thread, ->(mp) { mp.thread&.display_title&.truncate(25) }
-
column :created_at, ->(mp) { mp.created_at.strftime("%b %d, %H:%M") }
-
end
-
-
filters do
-
filter :status, type: :select, options: [
-
[ "All Statuses", "" ],
-
[ "Pending", "pending" ],
-
[ "Accepted", "accepted" ],
-
[ "Rejected", "rejected" ]
-
]
-
filter :user_id, type: :number, label: "User ID"
-
filter :thread_id, type: :number, label: "Thread ID"
-
filter :trace_id, type: :text, placeholder: "Trace ID..."
-
end
-
end
-
-
show do
-
sidebar do
-
panel :status, title: "Status", fields: [ :status, :confirmed_by, :confirmed_at ]
-
panel :ids, title: "Identifiers", fields: [ :trace_id ]
-
panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
-
end
-
-
main do
-
panel :user, title: "User", fields: [ :user ]
-
panel :thread, title: "Thread", fields: [ :thread ]
-
panel :proposal, title: "Proposed Items", fields: [ :proposed_items ]
-
end
-
end
-
-
exportable :json
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for Assistant Thread admin management
-
#
-
# Provides read-only operations with search, filtering, and export functionality.
-
class AssistantThreadResource < Admin::Base::Resource
-
model ::Assistant::ChatThread
-
portal :assistant
-
section :threads
-
-
index do
-
searchable :title
-
sortable default: :last_activity_at, direction: :desc
-
paginate 20
-
-
stats do
-
stat :total, -> { ::Assistant::ChatThread.count }
-
stat :open, -> { ::Assistant::ChatThread.where(status: "open").count }, color: :green
-
stat :closed, -> { ::Assistant::ChatThread.where(status: "closed").count }, color: :slate
-
stat :created_last_24h, -> { ::Assistant::ChatThread.where("created_at >= ?", 24.hours.ago).count }, color: :amber
-
end
-
-
columns do
-
column :id
-
column :title
-
column :user, ->(t) { t.user&.email_address }
-
column :status, sortable: true, type: :label, label_color: ->(t) {
-
case t.status.to_sym
-
when :open then :green
-
when :closed then :slate
-
else :gray
-
end
-
}
-
column :last_activity_at, ->(t) { t.last_activity_at&.strftime("%b %d, %H:%M") }, sortable: true
-
column :created_at, ->(t) { t.created_at&.strftime("%b %d, %H:%M") }, sortable: true
-
end
-
-
filters do
-
filter :status, type: :select, options: [
-
[ "All Statuses", "" ],
-
[ "Open", "open" ],
-
[ "Closed", "closed" ]
-
]
-
filter :user_id, type: :number, label: "User ID"
-
end
-
end
-
-
show do
-
sidebar do
-
panel :details, title: "Details", fields: [ :title, :status ]
-
panel :user, title: "User", fields: [ :user ]
-
panel :timestamps, title: "Activity", fields: [ :last_activity_at, :created_at, :updated_at ]
-
end
-
-
main do
-
panel :messages, title: "Messages", render: :messages_preview
-
panel :tool_executions, title: "Tool Executions",
-
association: :tool_executions,
-
limit: 20,
-
display: :table,
-
columns: [ :tool_key, :status, :duration_seconds, :created_at ],
-
link_to: :internal_developer_assistant_tool_execution_path
-
panel :turns, title: "Conversation Turns",
-
association: :turns,
-
limit: 20,
-
display: :list,
-
link_to: :internal_developer_assistant_turn_path
-
end
-
end
-
-
actions do
-
action :export, type: :link
-
end
-
-
exportable :json
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for Assistant Thread Summary admin management
-
#
-
# Provides read-only access to thread summaries for debugging.
-
class AssistantThreadSummaryResource < Admin::Base::Resource
-
model ::Assistant::Memory::ThreadSummary
-
portal :assistant
-
section :memory
-
-
index do
-
sortable :created_at, :summary_version, default: :created_at
-
paginate 30
-
-
stats do
-
stat :total, -> { ::Assistant::Memory::ThreadSummary.count }
-
stat :with_llm_log, -> { ::Assistant::Memory::ThreadSummary.where.not(llm_api_log_id: nil).count }, color: :blue
-
stat :recent_24h, -> { ::Assistant::Memory::ThreadSummary.where("created_at >= ?", 24.hours.ago).count }, color: :green
-
end
-
-
columns do
-
column :thread, ->(ts) { ts.thread&.display_title&.truncate(40) }
-
column :user, ->(ts) { ts.thread&.user&.email_address }
-
column :summary_version, header: "Version"
-
column :created_at, ->(ts) { ts.created_at.strftime("%b %d, %H:%M") }
-
end
-
-
filters do
-
filter :thread_id, type: :number, label: "Thread ID"
-
filter :user_id, type: :number, label: "User ID"
-
filter :version, type: :number, label: "Version"
-
end
-
end
-
-
show do
-
sidebar do
-
panel :meta, title: "Metadata", fields: [ :summary_version, :last_summarized_message ]
-
panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
-
end
-
-
main do
-
panel :thread, title: "Thread", fields: [ :thread ]
-
panel :content, title: "Summary Content", fields: [ :summary_text ]
-
panel :llm, title: "LLM API Log", association: :llm_api_log
-
end
-
end
-
-
exportable :json
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for Assistant Tool Execution admin management
-
#
-
# Provides operations to view, approve, enqueue, and replay tool executions.
-
class AssistantToolExecutionResource < Admin::Base::Resource
-
model ::Assistant::ToolExecution
-
portal :assistant
-
section :tools
-
-
index do
-
sortable :created_at, default: :created_at
-
paginate 30
-
-
stats do
-
stat :total, -> { ::Assistant::ToolExecution.count }
-
stat :proposed, -> { ::Assistant::ToolExecution.where(status: "proposed").count }, color: :slate
-
stat :queued, -> { ::Assistant::ToolExecution.where(status: "queued").count }, color: :blue
-
stat :running, -> { ::Assistant::ToolExecution.where(status: "running").count }, color: :amber
-
stat :success, -> { ::Assistant::ToolExecution.where(status: "success").count }, color: :green
-
stat :error, -> { ::Assistant::ToolExecution.where(status: "error").count }, color: :red
-
stat :confirmation_required, -> { ::Assistant::ToolExecution.where(requires_confirmation: true).count }, color: :purple
-
end
-
-
columns do
-
column :created_at, ->(te) { te.created_at.strftime("%b %d, %H:%M") }, header: "Time"
-
column :tool_key, header: "Tool"
-
column :status, type: :label, label_color: ->(te) {
-
case te.status.to_sym
-
when :proposed then :amber
-
when :queued then :blue
-
when :running then :indigo
-
when :success then :green
-
when :error then :red
-
when :cancelled then :slate
-
when :confirmation_required then :purple
-
else :gray
-
end
-
}
-
column :requires_confirmation, ->(te) { te.requires_confirmation? ? "Required" : "No" }, header: "Confirmation"
-
column :trace_id, ->(te) { te.trace_id&.truncate(12) }
-
end
-
-
filters do
-
filter :status, type: :select, options: [
-
[ "All Statuses", "" ],
-
[ "Proposed", "proposed" ],
-
[ "Queued", "queued" ],
-
[ "Running", "running" ],
-
[ "Success", "success" ],
-
[ "Error", "error" ]
-
]
-
filter :requires_confirmation, type: :select, label: "Confirmation", options: [
-
[ "All", "" ],
-
[ "Required", "true" ],
-
[ "Not Required", "false" ]
-
]
-
filter :tool_key, type: :text, placeholder: "Tool key..."
-
filter :trace_id, type: :text, placeholder: "Trace ID..."
-
end
-
end
-
-
show do
-
sidebar do
-
panel :status, title: "Status", fields: [ :status, :requires_confirmation ]
-
panel :approval, title: "Approval", fields: [ :approved_by, :approved_at ]
-
panel :timing, title: "Timing", fields: [ :started_at, :finished_at, :duration_seconds, :created_at ]
-
panel :thread, title: "Chat Thread",
-
association: :chat_thread,
-
display: :card,
-
link_to: :internal_developer_assistant_thread_path
-
end
-
-
main do
-
panel :tool, title: "Tool Information", fields: [ :tool_key, :trace_id, :provider_name, :provider_tool_call_id ]
-
panel :data, title: "Arguments & Result", render: :tool_args_preview
-
end
-
end
-
-
actions do
-
action :approve, method: :post, label: "Approve",
-
if: ->(te) { te.requires_confirmation && te.approved_by_id.nil? && te.status == "proposed" }
-
action :enqueue, method: :post, label: "Enqueue",
-
if: ->(te) { te.status == "proposed" && (!te.requires_confirmation || te.approved_by_id.present?) }
-
action :replay, method: :post, label: "Replay",
-
if: ->(te) { %w[success error].include?(te.status) }
-
-
bulk_action :bulk_approve, label: "Approve Selected"
-
bulk_action :bulk_enqueue, label: "Enqueue Selected"
-
end
-
-
exportable :json
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for Assistant Tool admin management
-
#
-
# Provides full CRUD for assistant tools with schema editing.
-
class AssistantToolResource < Admin::Base::Resource
-
model ::Assistant::Tool
-
portal :assistant
-
section :tools
-
-
index do
-
searchable :tool_key, :name, :description
-
sortable default: :tool_key, direction: :asc
-
paginate 30
-
-
stats do
-
stat :total, -> { ::Assistant::Tool.count }
-
stat :enabled, -> { ::Assistant::Tool.where(enabled: true).count }, color: :green
-
stat :read_only, -> { ::Assistant::Tool.where(risk_level: "read_only").count }, color: :blue
-
stat :write, -> { ::Assistant::Tool.where("risk_level LIKE 'write%'").count }, color: :amber
-
end
-
-
columns do
-
column :tool_key, header: "Key", sortable: true
-
column :name, sortable: true
-
column :risk_level, header: "Risk", sortable: true
-
column :enabled, type: :toggle, toggle_field: :enabled
-
column :requires_confirmation, ->(t) { t.requires_confirmation? ? "Yes" : "No" }, header: "Confirm"
-
column :timeout_ms, ->(t) { "#{t.timeout_ms}ms" }, header: "Timeout"
-
end
-
-
filters do
-
filter :enabled, type: :select, options: [
-
[ "All", "" ],
-
[ "Enabled", "true" ],
-
[ "Disabled", "false" ]
-
]
-
filter :risk_level, type: :select, label: "Risk", options: [
-
[ "All Levels", "" ],
-
[ "Read Only", "read_only" ],
-
[ "Write Low", "write_low" ],
-
[ "Write High", "write_high" ]
-
]
-
filter :requires_confirmation, type: :select, label: "Confirmation", options: [
-
[ "All", "" ],
-
[ "Required", "true" ],
-
[ "Not Required", "false" ]
-
]
-
end
-
end
-
-
form do
-
section "Tool Definition" do
-
field :tool_key, required: true, help: "Unique identifier (snake_case)"
-
field :name, required: true
-
field :description, type: :textarea, rows: 3, required: true
-
field :executor_class, required: true, help: "Fully qualified class name"
-
end
-
-
section "Configuration" do
-
row cols: 2 do
-
field :risk_level, type: :select, required: true, collection: [
-
[ "Read Only", "read_only" ],
-
[ "Write Low", "write_low" ],
-
[ "Write High", "write_high" ]
-
]
-
field :timeout_ms, type: :number, label: "Timeout (ms)"
-
end
-
-
row cols: 2 do
-
field :enabled, type: :toggle
-
field :requires_confirmation, type: :toggle
-
end
-
end
-
-
section "Schema" do
-
field :arg_schema, type: :json, label: "Argument Schema",
-
help: "JSON Schema for tool arguments"
-
field :rate_limit, type: :json, help: "Rate limiting configuration"
-
end
-
end
-
-
show do
-
sidebar do
-
panel :config, title: "Configuration", fields: [ :risk_level, :enabled, :requires_confirmation ]
-
panel :execution, title: "Execution", fields: [ :executor_class, :timeout_ms ]
-
panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
-
end
-
-
main do
-
panel :tool, title: "Tool Definition", fields: [ :tool_key, :name, :description ]
-
panel :schema, title: "Argument Schema", fields: [ :arg_schema ]
-
panel :limits, title: "Rate Limits", fields: [ :rate_limit ]
-
end
-
end
-
-
actions do
-
action :enable, method: :post, unless: ->(t) { t.enabled? }
-
action :disable, method: :post, if: ->(t) { t.enabled? }
-
end
-
-
exportable :json
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for Assistant Turn admin management
-
#
-
# Provides read-only access to conversation turns for debugging.
-
class AssistantTurnResource < Admin::Base::Resource
-
model ::Assistant::Turn
-
portal :assistant
-
section :turns
-
-
index do
-
sortable :created_at, default: :created_at
-
paginate 30
-
-
stats do
-
stat :total, -> { ::Assistant::Turn.count }
-
stat :success, -> { ::Assistant::Turn.where(status: "success").count }, color: :green
-
stat :error, -> { ::Assistant::Turn.where(status: "error").count }, color: :red
-
stat :last_24h, -> { ::Assistant::Turn.where("created_at >= ?", 24.hours.ago).count }, color: :blue
-
end
-
-
columns do
-
column :thread, ->(t) { t.thread&.display_title&.truncate(30) }
-
column :status, type: :label, label_color: ->(t) {
-
case t.status.to_sym
-
when :success then :green
-
when :error then :red
-
when :pending then :amber
-
else :gray
-
end
-
}
-
column :trace_id, ->(t) { t.trace_id&.truncate(12) }
-
column :latency_ms, ->(t) { t.latency_ms ? "#{t.latency_ms}ms" : "—" }, header: "Latency"
-
column :created_at, ->(t) { t.created_at.strftime("%b %d, %H:%M") }
-
end
-
-
filters do
-
filter :status, type: :select, options: [
-
[ "All Statuses", "" ],
-
[ "Success", "success" ],
-
[ "Error", "error" ],
-
[ "Pending", "pending" ]
-
]
-
filter :trace_id, type: :text, placeholder: "Trace ID..."
-
filter :thread_id, type: :number, label: "Thread ID"
-
end
-
end
-
-
show do
-
sidebar do
-
panel :status, title: "Status", fields: [ :status, :latency_ms ]
-
panel :ids, title: "Identifiers", fields: [ :trace_id, :uuid ]
-
panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
-
end
-
-
main do
-
panel :thread, title: "Thread", fields: [ :thread ]
-
panel :messages, title: "Messages", render: :turn_messages_preview
-
panel :context, title: "Context Snapshot", fields: [ :context_snapshot ]
-
panel :llm_log, title: "LLM API Log", association: :llm_api_log
-
end
-
end
-
-
exportable :json
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for Assistant User Memory admin management
-
#
-
# Provides read-only access to user memories with delete capability.
-
class AssistantUserMemoryResource < Admin::Base::Resource
-
model ::Assistant::Memory::UserMemory
-
portal :assistant
-
section :memory
-
-
index do
-
searchable :key
-
sortable :created_at, :expires_at, default: :created_at
-
paginate 30
-
-
stats do
-
stat :total, -> { ::Assistant::Memory::UserMemory.count }
-
stat :active, -> { ::Assistant::Memory::UserMemory.active.count }, color: :green
-
stat :expired, -> { ::Assistant::Memory::UserMemory.where("expires_at IS NOT NULL AND expires_at <= ?", Time.current).count }, color: :red
-
stat :user_source, -> { ::Assistant::Memory::UserMemory.where(source: "user").count }, color: :blue
-
stat :assistant_source, -> { ::Assistant::Memory::UserMemory.where(source: "assistant").count }, color: :amber
-
end
-
-
columns do
-
column :user, ->(um) { um.user&.email_address }
-
column :key, ->(um) { um.key&.truncate(40) }
-
column :source
-
column :active, ->(um) { (um.expires_at.nil? || um.expires_at > Time.current) ? "Yes" : "No" }
-
column :expires_at, ->(um) { um.expires_at&.strftime("%b %d, %Y") || "Never" }
-
column :created_at, ->(um) { um.created_at.strftime("%b %d, %H:%M") }
-
end
-
-
filters do
-
filter :source, type: :select, options: [
-
[ "All Sources", "" ],
-
[ "User", "user" ],
-
[ "Assistant", "assistant" ]
-
]
-
filter :active, type: :select, options: [
-
[ "All", "" ],
-
[ "Active", "true" ],
-
[ "Expired", "false" ]
-
]
-
filter :user_id, type: :number, label: "User ID"
-
end
-
end
-
-
show do
-
sidebar do
-
panel :meta, title: "Metadata", fields: [ :source, :expires_at ]
-
panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
-
end
-
-
main do
-
panel :user, title: "User", fields: [ :user ]
-
panel :memory, title: "Memory Content", fields: [ :key, :value ]
-
end
-
end
-
-
actions do
-
action :delete, method: :delete, label: "Delete",
-
confirm: "Delete this memory? This cannot be undone.",
-
color: :danger
-
end
-
-
exportable :json
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for Billing::Feature management.
-
class BillingFeatureResource < Admin::Base::Resource
-
model ::Billing::Feature
-
portal :payments
-
section :catalog
-
-
index do
-
searchable :key, :name, :kind, :unit
-
sortable :key, :updated_at, default: :updated_at
-
paginate 50
-
-
columns do
-
column :key
-
column :name
-
column :kind
-
column :unit
-
column :updated_at, ->(f) { f.updated_at&.strftime("%b %d, %H:%M") }
-
end
-
end
-
-
form do
-
section "Feature" do
-
row cols: 2 do
-
field :key, required: true, help: "Stable identifier used by gating checks."
-
field :name, required: true
-
end
-
row cols: 2 do
-
field :kind, type: :select, required: true, collection: ::Billing::Feature::KINDS.map { |v| [ v.humanize, v ] }
-
field :unit, placeholder: "e.g. ai_tokens, interviews"
-
end
-
field :description, type: :textarea, rows: 3
-
field :metadata, type: :json
-
end
-
end
-
end
-
end
-
end
-
-
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for Billing::PlanEntitlement management.
-
class BillingPlanEntitlementResource < Admin::Base::Resource
-
model ::Billing::PlanEntitlement
-
portal :payments
-
section :catalog
-
-
index do
-
searchable :enabled
-
sortable :updated_at, default: :updated_at
-
paginate 50
-
-
columns do
-
column :plan, ->(e) { e.plan&.key }, header: "Plan"
-
column :feature, ->(e) { e.feature&.key }, header: "Feature"
-
column :enabled, type: :toggle, toggle_field: :enabled
-
column :limit
-
column :updated_at, ->(e) { e.updated_at&.strftime("%b %d, %H:%M") }
-
end
-
end
-
-
form do
-
section "Entitlement" do
-
field :plan_id, type: :select, required: true,
-
collection: -> { ::Billing::Plan.ordered.pluck(:name, :id) }
-
field :feature_id, type: :select, required: true,
-
collection: -> { ::Billing::Feature.order(:key).pluck(:key, :id) }
-
row cols: 2 do
-
field :enabled, type: :toggle
-
field :limit, type: :number, help: "Only used for quota features."
-
end
-
field :metadata, type: :json
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for Billing::Plan management.
-
class BillingPlanResource < Admin::Base::Resource
-
model ::Billing::Plan
-
portal :payments
-
section :catalog
-
-
index do
-
searchable :key, :name, :plan_type
-
sortable :name, :key, :updated_at, default: :updated_at
-
paginate 50
-
-
columns do
-
column :key
-
column :name
-
column :plan_type, header: "Type"
-
column :interval
-
column :amount_cents, header: "Amount (cents)"
-
column :currency
-
column :published, type: :toggle, toggle_field: :published
-
column :highlighted, type: :toggle, toggle_field: :highlighted
-
column :updated_at, ->(p) { p.updated_at&.strftime("%b %d, %H:%M") }
-
end
-
-
filters do
-
filter :plan_type, type: :select, label: "Type", options: [
-
[ "All", "" ],
-
[ "Free", "free" ],
-
[ "Recurring", "recurring" ],
-
[ "One-time", "one_time" ]
-
]
-
filter :published, type: :select, label: "Published", options: [
-
[ "All", "" ],
-
[ "Published", "true" ],
-
[ "Unpublished", "false" ]
-
]
-
end
-
end
-
-
form do
-
section "Plan" do
-
row cols: 2 do
-
field :key, required: true, help: "Stable identifier used for gating and pricing surfaces."
-
field :name, required: true
-
end
-
-
field :description, type: :textarea, rows: 3
-
-
row cols: 3 do
-
field :plan_type, type: :select, required: true, collection: ::Billing::Plan::PLAN_TYPES.map { |v| [ v.humanize, v ] }
-
field :interval, type: :select, collection: [ [ "", "" ] ] + ::Billing::Plan::INTERVALS.map { |v| [ v.humanize, v ] }
-
field :currency, required: true, placeholder: "eur"
-
end
-
-
row cols: 3 do
-
field :amount_cents, type: :number, placeholder: "e.g. 1200"
-
field :sort_order, type: :number
-
field :highlighted, type: :toggle
-
end
-
-
field :published, type: :toggle
-
field :metadata, type: :json, help: "Free-form plan metadata (e.g. marketing copy variants)."
-
end
-
end
-
-
show do
-
sidebar do
-
panel :details, title: "Details", fields: [ :key, :name, :description, :plan_type, :interval, :currency, :amount_cents, :sort_order, :highlighted ]
-
panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
-
end
-
-
main do
-
panel :provider_mappings, title: "Provider Mappings",
-
association: :provider_mappings,
-
display: :table,
-
columns: [ :provider, :product_id, :variant_id ],
-
link_to: :internal_developer_ops_billing_provider_mapping_path
-
panel :features, title: "Features",
-
association: :features, display: :table,
-
columns: [ :key, :name, :kind, :unit ],
-
resource: Admin::Resources::BillingFeatureResource,
-
link_to: :internal_developer_ops_billing_feature_path,
-
paginate: true, per_page: 10
-
panel :entitlements, title: "Entitlements",
-
association: :plan_entitlements,
-
display: :table,
-
columns: [ :feature, :enabled, :limit ],
-
resource: Admin::Resources::BillingPlanEntitlementResource,
-
link_to: :internal_developer_ops_billing_plan_entitlement_path,
-
paginate: true, per_page: 5
-
panel :metadata, title: "Metadata", fields: [ :metadata ]
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for Billing::ProviderMapping management.
-
class BillingProviderMappingResource < Admin::Base::Resource
-
model ::Billing::ProviderMapping
-
portal :payments
-
section :providers
-
-
index do
-
searchable :provider, :external_product_id, :external_variant_id
-
sortable :updated_at, default: :updated_at
-
paginate 50
-
-
columns do
-
column :provider
-
column :plan, ->(m) { m.plan&.key }, header: "Plan"
-
column :external_product_id, header: "Product"
-
column :external_variant_id, header: "Variant"
-
column :updated_at, ->(m) { m.updated_at&.strftime("%b %d, %H:%M") }
-
end
-
end
-
-
form do
-
section "Provider Mapping" do
-
field :plan_id, type: :select, required: true,
-
collection: -> { ::Billing::Plan.ordered.pluck(:name, :id) }
-
row cols: 2 do
-
field :provider, type: :select, required: true, collection: ::Billing::ProviderMapping::PROVIDERS.map { |v| [ v.humanize, v ] }
-
field :external_product_id, placeholder: "LemonSqueezy product id"
-
end
-
row cols: 2 do
-
field :external_variant_id, placeholder: "LemonSqueezy variant id"
-
field :external_price_id, placeholder: "Optional price id"
-
end
-
field :metadata, type: :json, help: "Provider-specific config (e.g., store_id for LemonSqueezy)."
-
end
-
end
-
end
-
end
-
end
-
-
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for Billing::Subscription viewing (read-only via routes).
-
class BillingSubscriptionResource < Admin::Base::Resource
-
model ::Billing::Subscription
-
portal :payments
-
section :runtime
-
-
index do
-
searchable :provider, :status, :external_subscription_id
-
sortable :updated_at, :created_at, default: :updated_at
-
paginate 50
-
-
columns do
-
column :user_id, header: "User ID"
-
column :provider
-
column :status, type: :label, label_color: :green
-
column :plan, ->(s) { s.plan&.key }, header: "Plan"
-
column :external_subscription_id, header: "External ID"
-
column :current_period_ends_at, ->(s) { s.current_period_ends_at&.strftime("%b %d, %H:%M") }
-
column :updated_at, ->(s) { s.updated_at&.strftime("%b %d, %H:%M") }
-
end
-
end
-
-
show do
-
sidebar do
-
panel :status, title: "Status", fields: [ :status, :provider, :external_subscription_id ]
-
panel :timing, title: "Timing", fields: [ :trial_ends_at, :current_period_starts_at, :current_period_ends_at, :cancel_at_period_end, :cancelled_at ]
-
panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
-
end
-
-
main do
-
panel :metadata, title: "Metadata", fields: [ :metadata ]
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for Billing::WebhookEvent viewing (read-only via routes).
-
class BillingWebhookEventResource < Admin::Base::Resource
-
model ::Billing::WebhookEvent
-
portal :payments
-
section :runtime
-
-
index do
-
searchable :provider, :event_type, :status, :idempotency_key
-
sortable :received_at, :updated_at, default: :received_at, direction: :desc
-
paginate 50
-
-
columns do
-
column :provider
-
column :event_type, header: "Event"
-
column :status, type: :label, label_color: ->(we) {
-
case we.status.to_sym
-
when :pending then :amber
-
when :processed then :green
-
when :failed then :red
-
when :ignored then :slate
-
else :gray
-
end
-
}
-
column :received_at
-
column :processed_at
-
column :idempotency_key, header: "Key"
-
end
-
-
filters do
-
filter :status, type: :select, label: "Status", options: [
-
[ "All", "" ],
-
[ "Pending", "pending" ],
-
[ "Processed", "processed" ],
-
[ "Failed", "failed" ],
-
[ "Ignored", "ignored" ]
-
]
-
end
-
end
-
-
show do
-
sidebar do
-
panel :status, title: "Status", fields: [ :provider, :event_type, :status, :received_at, :processed_at ]
-
panel :error, title: "Error", fields: [ :error_message ]
-
end
-
-
main do
-
panel :payload, title: "Payload", fields: [ :payload ]
-
end
-
end
-
-
actions do
-
action :replay, method: :post, label: "Replay"
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for Blog Post admin management
-
#
-
# Provides full CRUD operations for blog posts with markdown preview.
-
class BlogPostResource < Admin::Base::Resource
-
model BlogPost
-
portal :ops
-
section :content
-
-
index do
-
searchable :title, :slug
-
sortable :title, :created_at, :published_at, default: :created_at
-
paginate 30
-
-
stats do
-
stat :total, -> { BlogPost.count }
-
stat :published, -> { BlogPost.where(status: "published").count }, color: :green
-
stat :draft, -> { BlogPost.where(status: "draft").count }, color: :slate
-
end
-
-
columns do
-
column :title
-
column :slug
-
column :status, type: :label, label_color: ->(bp) { bp.status == "published" ? :green : :slate }
-
column :author_name, header: "Author"
-
column :published_at, ->(bp) { bp.published_at&.strftime("%b %d, %Y") || "—" }
-
end
-
-
filters do
-
filter :status, type: :select, options: [
-
[ "All Statuses", "" ],
-
[ "Published", "published" ],
-
[ "Draft", "draft" ]
-
]
-
filter :sort, type: :select, options: [
-
[ "Recently Added", "recent" ],
-
[ "Title (A-Z)", "title" ],
-
[ "Published Date", "published_at" ]
-
]
-
end
-
end
-
-
form do
-
section "Post Details" do
-
field :title, required: true
-
field :slug, help: "URL-friendly identifier (auto-generated if blank)"
-
field :author_name, label: "Author"
-
field :excerpt, type: :textarea, rows: 2, help: "Brief summary for listings"
-
end
-
-
section "Cover Image" do
-
field :cover_image, type: :image,
-
accept: "image/jpeg,image/png,image/webp",
-
help: "Recommended size: 1200x630 pixels for best social media preview"
-
end
-
-
section "Content" do
-
field :body, type: :markdown, rows: 20, help: "Supports Markdown formatting"
-
end
-
-
section "Publishing" do
-
row cols: 2 do
-
field :status, type: :select, collection: [
-
[ "Draft", "draft" ],
-
[ "Published", "published" ]
-
]
-
field :published_at, type: :datetime
-
end
-
field :tag_list, type: :tags, label: "Tags",
-
collection: -> { ActsAsTaggableOn::Tag.most_used(20).pluck(:name) },
-
creatable: true,
-
placeholder: "Add tags...",
-
help: "Select existing tags or create new ones"
-
end
-
end
-
-
show do
-
sidebar do
-
panel :cover, title: "Cover Image", fields: [ :cover_image ]
-
panel :meta, title: "Post Info", fields: [ :slug, :status, :author_name ]
-
panel :dates, title: "Dates", fields: [ :published_at, :created_at, :updated_at ]
-
end
-
-
main do
-
panel :excerpt, title: "Excerpt", fields: [ :excerpt ]
-
panel :content, title: "Content", fields: [ :body ]
-
panel :tags, title: "Tags", fields: [ :tag_list ]
-
end
-
end
-
-
actions do
-
action :publish, method: :post, if: ->(bp) { bp.status == "draft" }
-
action :unpublish, method: :post, if: ->(bp) { bp.status == "published" }
-
end
-
-
exportable :json
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for Category admin management
-
#
-
# Provides CRUD operations with search, filtering, and merge functionality.
-
class CategoryResource < Admin::Base::Resource
-
model Category
-
portal :ops
-
section :content
-
-
index do
-
searchable :name
-
sortable :name, :created_at, default: :name
-
paginate 30
-
-
stats do
-
stat :total, -> { Category.count }
-
stat :with_job_roles, -> { Category.joins(:job_roles).distinct.count }, color: :blue
-
end
-
-
columns do
-
column :name
-
column :job_roles_count, ->(c) { c.job_roles.count }, header: "Job Roles"
-
end
-
-
filters do
-
filter :sort, type: :select, options: [
-
[ "Name (A-Z)", "name" ],
-
[ "Recently Added", "recent" ]
-
]
-
end
-
end
-
-
form do
-
field :name, required: true, placeholder: "Category name"
-
end
-
-
show do
-
sidebar do
-
panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
-
end
-
-
main do
-
panel :job_roles, title: "Job Roles", association: :job_roles, limit: 20, display: :list
-
end
-
end
-
-
actions do
-
action :disable, method: :post, confirm: "Disable this category?"
-
action :enable, method: :post
-
action :merge, type: :modal
-
end
-
-
exportable :json, :csv
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for CompanyFeedback admin management
-
#
-
# Provides read operations for viewing company feedback from users.
-
class CompanyFeedbackResource < Admin::Base::Resource
-
model CompanyFeedback
-
portal :ops
-
section :support
-
-
index do
-
sortable :created_at, default: :created_at
-
paginate 30
-
-
stats do
-
stat :total, -> { CompanyFeedback.count }
-
stat :this_month, -> { CompanyFeedback.where("created_at >= ?", 1.month.ago).count }, color: :blue
-
end
-
-
columns do
-
column :id
-
column :company, ->(cf) { cf.interview_application&.company&.name }
-
column :user, ->(cf) { cf.interview_application&.user&.email_address }
-
column :rating
-
column :created_at, ->(cf) { cf.created_at.strftime("%b %d, %Y") }
-
end
-
-
filters do
-
filter :rating, type: :select, options: (1..5).map { |n| [ "#{n} Stars", n ] }
-
end
-
end
-
-
show do
-
section :details, fields: [ :rating, :created_at, :updated_at ]
-
section :feedback, fields: [ :feedback_text ]
-
section :application, fields: [ :interview_application ]
-
end
-
-
exportable :json
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for Company admin management
-
#
-
# Provides CRUD operations with search, filtering, and merge functionality.
-
class CompanyResource < Admin::Base::Resource
-
model Company
-
portal :ops
-
section :content
-
-
index do
-
searchable :name, :website
-
sortable :name, :created_at, default: :name
-
paginate 30
-
-
stats do
-
stat :total, -> { Company.count }
-
stat :with_website, -> { Company.where.not(website: [ nil, "" ]).count }, color: :blue
-
stat :with_logo, -> { Company.where.not(logo_url: [ nil, "" ]).count }, color: :green
-
stat :with_job_listings, -> { Company.joins(:job_listings).distinct.count }, color: :amber
-
end
-
-
columns do
-
column :name, header: "Company"
-
column :website
-
column :job_listings_count, ->(c) { c.job_listings.count }, header: "Jobs"
-
column :applications_count, ->(c) { c.interview_applications.count }, header: "Apps"
-
end
-
-
filters do
-
filter :sort, type: :select, options: [
-
[ "Name (A-Z)", "name" ],
-
[ "Recently Added", "recent" ]
-
]
-
filter :has_website, type: :toggle, label: "Has Website", scope: ->(scope) { scope.where.not(website: [ nil, "" ]) }
-
end
-
end
-
-
form do
-
field :name, required: true, placeholder: "Company name"
-
field :website, type: :url, placeholder: "https://example.com"
-
field :about, type: :textarea, rows: 4, help: "Brief description of the company"
-
field :logo_url, type: :url, label: "Logo URL", help: "URL to company logo image"
-
end
-
-
show do
-
sidebar do
-
panel :info, title: "Company Info", fields: [ :website, :logo_url ]
-
panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
-
end
-
-
main do
-
panel :about, title: "About", fields: [ :about ]
-
panel :job_listings, title: "Job Listings",
-
association: :job_listings,
-
limit: 10,
-
display: :table,
-
columns: [ :title, :status, :remote_type, :created_at ],
-
link_to: :internal_developer_ops_job_listing_path
-
panel :applications, title: "Interview Applications",
-
association: :interview_applications,
-
limit: 10,
-
display: :table,
-
columns: [ :status, :job_listing, :user, :created_at ],
-
link_to: :internal_developer_ops_interview_application_path
-
end
-
end
-
-
actions do
-
action :disable, method: :post, confirm: "Disable this company?", unless: ->(c) { c.disabled? }
-
action :enable, method: :post, if: ->(c) { c.disabled? }
-
action :merge, type: :modal
-
bulk_action :bulk_disable, label: "Disable Selected"
-
end
-
-
exportable :json, :csv
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for Connected Account admin management
-
#
-
# Provides read-only access to OAuth connected accounts for debugging.
-
class ConnectedAccountResource < Admin::Base::Resource
-
model ConnectedAccount
-
portal :ops
-
section :users
-
-
index do
-
searchable :email
-
sortable :created_at, :last_synced_at, default: :created_at
-
paginate 30
-
-
stats do
-
stat :total, -> { ConnectedAccount.count }
-
stat :google, -> { ConnectedAccount.google.count }, color: :blue
-
stat :sync_enabled, -> { ConnectedAccount.sync_enabled.count }, color: :green
-
stat :expired, -> { ConnectedAccount.expired.count }, color: :red
-
stat :valid, -> { ConnectedAccount.valid_tokens.count }, color: :green
-
end
-
-
columns do
-
column :user, ->(ca) { ca.user&.email_address }
-
column :provider
-
column :email
-
column :sync_enabled, ->(ca) { ca.sync_enabled? ? "Yes" : "No" }, header: "Sync"
-
column :token_status, ->(ca) {
-
if ca.token_expired?
-
"Expired"
-
elsif ca.expires_at && ca.expires_at < 5.minutes.from_now
-
"Expiring"
-
else
-
"Valid"
-
end
-
}, header: "Token"
-
column :last_synced_at, ->(ca) { ca.last_synced_at&.strftime("%b %d, %H:%M") || "Never" }
-
end
-
-
filters do
-
filter :provider, type: :select, options: [
-
["All Providers", ""],
-
["Google", "google_oauth2"]
-
]
-
filter :sync_enabled, type: :select, label: "Sync", options: [
-
["All", ""],
-
["Enabled", "true"],
-
["Disabled", "false"]
-
]
-
filter :token_status, type: :select, label: "Token", options: [
-
["All", ""],
-
["Valid", "valid"],
-
["Expired", "expired"],
-
["Expiring Soon", "expiring_soon"]
-
]
-
filter :sort, type: :select, options: [
-
["Recently Added", "recent"],
-
["Last Synced", "last_synced"],
-
["User Name", "user"]
-
]
-
end
-
end
-
-
show do
-
sidebar do
-
panel :account, title: "Account", fields: [:email, :provider, :uid]
-
panel :sync, title: "Sync Status", fields: [:sync_enabled, :last_synced_at]
-
panel :token, title: "Token", fields: [:expires_at]
-
panel :timestamps, title: "Timestamps", fields: [:created_at, :updated_at]
-
end
-
-
main do
-
panel :user, title: "User", fields: [:user]
-
panel :emails, title: "Recent Synced Emails", association: :synced_emails, limit: 20, display: :list
-
end
-
end
-
-
exportable :json
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
class EmailPipelineEventResource < Admin::Base::Resource
-
model Signals::EmailPipelineEvent
-
portal :email
-
section :pipeline
-
-
index do
-
sortable :created_at, default: :created_at, direction: :desc
-
paginate 50
-
-
stats do
-
stat :total, -> { Signals::EmailPipelineEvent.count }
-
stat :success, -> { Signals::EmailPipelineEvent.where(status: :success).count }, color: :green
-
stat :failed, -> { Signals::EmailPipelineEvent.where(status: :failed).count }, color: :red
-
stat :started, -> { Signals::EmailPipelineEvent.where(status: :started).count }, color: :amber
-
end
-
-
columns do
-
column :event_type, header: "Event"
-
column :status, type: :label, label_color: ->(e) {
-
case e.status.to_sym
-
when :started then :amber
-
when :success then :green
-
when :failed then :red
-
when :skipped then :purple
-
else :gray
-
end
-
}
-
column :duration_ms, header: "Duration"
-
column :step_order, header: "Step"
-
column :run_id, header: "Run"
-
column :synced_email_id, header: "Email"
-
column :created_at, ->(e) { e.created_at.strftime("%b %d, %H:%M:%S") }
-
end
-
-
filters do
-
filter :status, type: :select, options: [
-
[ "All", "" ],
-
[ "Started", "started" ],
-
[ "Success", "success" ],
-
[ "Failed", "failed" ],
-
[ "Skipped", "skipped" ]
-
]
-
filter :event_type, type: :text, placeholder: "event_type..."
-
end
-
end
-
-
show do
-
sidebar do
-
panel :meta, title: "Event", fields: [ :event_type, :status, :duration_ms, :step_order ]
-
panel :timestamps, title: "Timestamps", fields: [ :started_at, :completed_at, :created_at ]
-
panel :run, title: "Run", association: :run
-
panel :email, title: "Email", association: :synced_email
-
end
-
-
main do
-
panel :error, title: "Error", fields: [ :error_type, :error_message ]
-
panel :input, title: "Input Payload", fields: [ :input_payload ]
-
panel :output, title: "Output Payload", fields: [ :output_payload ]
-
panel :metadata, title: "Metadata", fields: [ :metadata ]
-
end
-
end
-
-
exportable :json
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
class EmailPipelineRunResource < Admin::Base::Resource
-
model Signals::EmailPipelineRun
-
portal :email
-
section :pipeline
-
-
index do
-
sortable :created_at, default: :created_at, direction: :desc
-
paginate 30
-
-
stats do
-
stat :total, -> { Signals::EmailPipelineRun.count }
-
stat :started, -> { Signals::EmailPipelineRun.where(status: :started).count }, color: :amber
-
stat :success, -> { Signals::EmailPipelineRun.where(status: :success).count }, color: :green
-
stat :failed, -> { Signals::EmailPipelineRun.where(status: :failed).count }, color: :red
-
end
-
-
columns do
-
column :id
-
column :status, type: :label, label_color: ->(r) {
-
case r.status.to_sym
-
when :started then :amber
-
when :success then :green
-
when :failed then :red
-
else :gray
-
end
-
}
-
column :trigger
-
column :mode
-
column :synced_email_id, header: "Email"
-
column :duration_ms, header: "Duration"
-
column :created_at, ->(r) { r.created_at.strftime("%b %d, %H:%M:%S") }
-
end
-
-
filters do
-
filter :status, type: :select, options: [
-
[ "All", "" ],
-
[ "Started", "started" ],
-
[ "Success", "success" ],
-
[ "Failed", "failed" ]
-
]
-
filter :trigger, type: :text, placeholder: "gmail_sync / manual ..."
-
end
-
end
-
-
show do
-
sidebar do
-
panel :meta, title: "Run", fields: [ :status, :trigger, :mode ]
-
panel :timestamps, title: "Timestamps", fields: [ :started_at, :completed_at, :created_at ]
-
panel :email, title: "Synced Email", association: :synced_email
-
end
-
-
main do
-
panel :events, title: "Events", association: :events, display: :table, columns: [ :step_order, :event_type, :status, :duration_ms, :created_at ]
-
panel :error, title: "Error", fields: [ :error_type, :error_message ]
-
panel :metadata, title: "Metadata", fields: [ :metadata ]
-
end
-
end
-
-
exportable :json
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for Email Sender admin management
-
#
-
# Provides CRUD for email senders discovered during Gmail sync with company assignment.
-
class EmailSenderResource < Admin::Base::Resource
-
model EmailSender
-
portal :ops
-
section :email
-
-
index do
-
searchable :email, :name, :domain
-
sortable :email, :created_at, default: :created_at
-
paginate 30
-
-
stats do
-
stat :total, -> { EmailSender.count }
-
stat :unassigned, -> { EmailSender.unassigned.count }, color: :amber
-
stat :assigned, -> { EmailSender.assigned.count }, color: :green
-
stat :auto_detected, -> { EmailSender.auto_detected.count }, color: :blue
-
stat :verified, -> { EmailSender.verified.count }, color: :green
-
end
-
-
columns do
-
column :email
-
column :name
-
column :domain
-
column :company, ->(es) { es.company&.name || "—" }
-
column :sender_type, type: :label, label_color: ->(es) {
-
case es.sender_type.to_sym
-
when :recruiter then :green
-
when :hiring_manager then :blue
-
when :hr then :purple
-
when :ats_system then :indigo
-
when :company then :green
-
when :unknown then :slate
-
else :gray
-
end
-
}
-
column :verified, type: :toggle, toggle_field: :verified
-
end
-
-
filters do
-
filter :status, type: :select, options: [
-
[ "All", "" ],
-
[ "Unassigned", "unassigned" ],
-
[ "Assigned", "assigned" ],
-
[ "Auto Detected", "auto_detected" ],
-
[ "Verified", "verified" ]
-
]
-
filter :sender_type, type: :select, label: "Type", options: EmailSender.sender_types_for_select
-
filter :sort, type: :select, options: [
-
[ "Recently Added", "recent" ],
-
[ "Email Count", "email_count" ],
-
[ "Last Seen", "last_seen" ],
-
[ "Alphabetical", "alphabetical" ]
-
]
-
end
-
end
-
-
form do
-
section "Sender Information" do
-
field :email, readonly: true
-
field :name
-
field :domain, readonly: true
-
end
-
-
section "Assignment" do
-
field :company_id, type: :searchable_select, label: "Company",
-
collection: -> { Company.order(:name).pluck(:name, :id) },
-
placeholder: "Search for a company..."
-
field :sender_type, type: :select, collection: EmailSender.sender_types_for_select
-
field :verified, type: :toggle, help: "Verified company assignment"
-
end
-
end
-
-
show do
-
sidebar do
-
panel :info, title: "Sender Info", fields: [ :email, :name, :domain ]
-
panel :assignment, title: "Assignment", fields: [ :company, :sender_type, :verified ]
-
panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
-
end
-
-
main do
-
panel :emails, title: "Related Emails", association: :synced_emails, limit: 20, display: :list
-
end
-
end
-
-
actions do
-
action :verify, method: :post, unless: ->(es) { es.verified? }
-
bulk_action :bulk_assign, label: "Assign to Company"
-
end
-
-
exportable :json, :csv
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for HTML Scraping Log admin management
-
#
-
# Provides read-only access to HTML scraping logs for debugging.
-
class HtmlScrapingLogResource < Admin::Base::Resource
-
model HtmlScrapingLog
-
portal :ops
-
section :scraping
-
-
index do
-
sortable :created_at, default: :created_at
-
paginate 30
-
-
stats do
-
stat :total_7d, -> { HtmlScrapingLog.recent_period(7).count }
-
stat :success, -> { HtmlScrapingLog.recent_period(7).where(status: :success).count }, color: :green
-
stat :partial, -> { HtmlScrapingLog.recent_period(7).where(status: :partial).count }, color: :amber
-
stat :failed, -> { HtmlScrapingLog.recent_period(7).where(status: :failed).count }, color: :red
-
end
-
-
columns do
-
column :domain
-
column :status, type: :label, label_color: ->(log) {
-
case log.status.to_sym
-
when :success then :green
-
when :partial then :amber
-
when :failed then :red
-
else :gray
-
end
-
}
-
column :extraction_rate, ->(log) { "#{(log.extraction_rate.to_f * 100).round(1)}%" }, header: "Rate"
-
column :duration, ->(log) { log.duration_ms ? "#{log.duration_ms}ms" : "—" }
-
column :fetch_mode, header: "Mode"
-
column :board_type, header: "Board"
-
column :created_at, ->(log) { log.created_at.strftime("%b %d, %H:%M") }
-
end
-
-
filters do
-
filter :status, type: :select, options: [
-
[ "All Statuses", "" ],
-
[ "Success", "success" ],
-
[ "Partial", "partial" ],
-
[ "Failed", "failed" ]
-
]
-
filter :fetch_mode, type: :select, label: "Mode", options: [
-
[ "All Modes", "" ],
-
[ "Direct", "direct" ],
-
[ "Browser", "browser" ]
-
]
-
filter :board_type, type: :select, label: "Board", options: [
-
[ "All Boards", "" ],
-
[ "Greenhouse", "greenhouse" ],
-
[ "Lever", "lever" ],
-
[ "Workday", "workday" ],
-
[ "Custom", "custom" ]
-
]
-
filter :date_from, type: :date, label: "From Date"
-
filter :date_to, type: :date, label: "To Date"
-
end
-
end
-
-
show do
-
sidebar do
-
panel :meta, title: "Log Info", fields: [ :domain, :status, :fetch_mode, :board_type ]
-
panel :performance, title: "Performance", fields: [ :duration_ms, :extraction_rate ]
-
panel :context, title: "Context", fields: [ :extractor_kind, :run_context ]
-
panel :timestamps, title: "Timestamps", fields: [ :created_at ]
-
panel :attempt, title: "Scraping Attempt",
-
association: :scraping_attempt,
-
link_to: :internal_developer_ops_scraping_attempt_path
-
panel :job, title: "Job Listing",
-
association: :job_listing,
-
link_to: :internal_developer_ops_job_listing_path
-
end
-
-
main do
-
panel :fields, title: "Field Results", fields: [ :field_results ]
-
panel :html, title: "HTML Content", fields: [ :raw_html ]
-
end
-
end
-
-
exportable :json
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for Interview Application admin management
-
#
-
# Provides read-only access to interview applications with filtering and search.
-
class InterviewApplicationResource < Admin::Base::Resource
-
model InterviewApplication
-
portal :ops
-
section :applications
-
-
index do
-
searchable :user, :company, :job_role
-
sortable :created_at, :applied_at, default: :created_at
-
paginate 25
-
-
stats do
-
stat :total, -> { InterviewApplication.count }
-
stat :active, -> { InterviewApplication.where(status: "active").count }, color: :green
-
stat :with_rounds, -> { InterviewApplication.joins(:interview_rounds).distinct.count }, color: :blue
-
stat :with_feedback, -> { InterviewApplication.joins(:company_feedback).distinct.count }, color: :amber
-
end
-
-
columns do
-
column :user, ->(ia) { ia.user&.email_address }
-
column :company, ->(ia) { ia.company&.name }
-
column :job_role, ->(ia) { ia.job_role&.title }
-
column :status, type: :label, label_color: ->(ia) {
-
case ia.status.to_sym
-
when :active then :indigo
-
when :interviewing then :purple
-
when :offer then :green
-
when :rejected then :red
-
when :archived then :slate
-
else :slate
-
end
-
}
-
column :pipeline_stage, type: :label, label_color: ->(ia) {
-
case ia.pipeline_stage.to_sym
-
when :applied then :indigo
-
when :screening then :purple
-
when :interviewing then :blue
-
when :offer then :green
-
when :closed then :red
-
else :gray
-
end
-
}
-
column :applied_at, ->(ia) { ia.applied_at&.strftime("%b %d, %Y") || "—" }
-
end
-
-
filters do
-
filter :status, type: :select, options: [
-
[ "All Statuses", "" ],
-
[ "Active", "active" ],
-
[ "Interviewing", "interviewing" ],
-
[ "Offer", "offer" ],
-
[ "Rejected", "rejected" ],
-
[ "Withdrawn", "withdrawn" ]
-
]
-
filter :pipeline_stage, type: :select, label: "Stage", options: [
-
[ "All Stages", "" ],
-
[ "Applied", "applied" ],
-
[ "Screening", "screening" ],
-
[ "Interviewing", "interviewing" ],
-
[ "Offer", "offer" ],
-
[ "Closed", "closed" ]
-
]
-
filter :sort, type: :select, options: [
-
[ "Recently Added", "recent" ],
-
[ "Applied Date", "applied_at" ],
-
[ "User Name", "user" ]
-
]
-
end
-
end
-
-
show do
-
sidebar do
-
panel :status, title: "Status", fields: [ :status, :pipeline_stage ]
-
panel :dates, title: "Key Dates", fields: [ :applied_at, :created_at, :updated_at ]
-
end
-
-
main do
-
panel :user, title: "Applicant", fields: [ :user ]
-
panel :position, title: "Position", fields: [ :company, :job_role, :job_listing ]
-
panel :rounds, title: "Interview Rounds", association: :interview_rounds, limit: 20, display: :list
-
panel :feedback, title: "Company Feedback", association: :company_feedback
-
panel :emails, title: "Related Emails", association: :synced_emails, limit: 10, display: :list
-
end
-
end
-
-
exportable :json, :csv
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for InterviewRound admin management
-
#
-
# Provides read operations for viewing interview rounds.
-
class InterviewRoundResource < Admin::Base::Resource
-
model InterviewRound
-
portal :ops
-
section :support
-
-
index do
-
sortable :scheduled_at, :created_at, default: :scheduled_at
-
paginate 30
-
-
stats do
-
stat :total, -> { InterviewRound.count }
-
stat :scheduled, -> { InterviewRound.where("scheduled_at > ?", Time.current).count }, color: :blue
-
stat :completed, -> { InterviewRound.where("scheduled_at < ?", Time.current).count }, color: :green
-
end
-
-
columns do
-
column :id
-
column :interview_application, ->(ir) { "App ##{ir.interview_application_id}" }
-
column :round_type
-
column :scheduled_at, ->(ir) { ir.scheduled_at&.strftime("%b %d, %Y %H:%M") }
-
column :status
-
end
-
-
filters do
-
filter :round_type, type: :select, options: [
-
[ "All Types", "" ],
-
[ "Phone Screen", "phone_screen" ],
-
[ "Technical", "technical" ],
-
[ "Onsite", "onsite" ],
-
[ "Final", "final" ]
-
]
-
filter :status, type: :select, options: [
-
[ "All Statuses", "" ],
-
[ "Scheduled", "scheduled" ],
-
[ "Completed", "completed" ],
-
[ "Cancelled", "cancelled" ]
-
]
-
end
-
end
-
-
show do
-
section :details, fields: [
-
:round_type, :status, :scheduled_at, :duration_minutes,
-
:location, :notes, :created_at, :updated_at
-
]
-
section :application, fields: [ :interview_application ]
-
section :feedback, association: :interview_feedback
-
end
-
-
exportable :json
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for InterviewRoundType admin management
-
#
-
# Provides CRUD operations for managing interview round types per department.
-
# Round types with no category are universal (available to all departments).
-
class InterviewRoundTypeResource < Admin::Base::Resource
-
model InterviewRoundType
-
portal :ops
-
section :content
-
-
index do
-
searchable :name, :slug
-
sortable :name, :position, :created_at, default: :position
-
paginate 30
-
-
stats do
-
stat :total, -> { InterviewRoundType.count }
-
stat :universal, -> { InterviewRoundType.universal.count }, color: :blue
-
stat :enabled, -> { InterviewRoundType.enabled.count }, color: :green
-
end
-
-
columns do
-
column :name
-
column :slug
-
column :department, ->(rt) { rt.category&.name || "Universal" }
-
column :position
-
column :rounds_count, ->(rt) { rt.interview_rounds.count }, header: "Rounds"
-
column :status, ->(rt) { rt.disabled? ? "Disabled" : "Enabled" }
-
end
-
-
filters do
-
filter :category_id, type: :select, label: "Department",
-
options: -> { [ [ "Universal", "nil" ] ] + Category.departments.pluck(:name, :id) }
-
filter :disabled_at, type: :select, label: "Status",
-
options: [ [ "Enabled", "nil" ], [ "Disabled", "not_nil" ] ]
-
end
-
end
-
-
form do
-
field :name, required: true, placeholder: "Round type name (e.g., 'Coding Interview')"
-
field :slug, required: true, placeholder: "Slug (e.g., 'coding')"
-
field :category_id, type: :select, label: "Department",
-
collection: -> { [ [ "Universal (all departments)", nil ] ] + Category.departments.pluck(:name, :id) }
-
field :description, type: :textarea, rows: 3, placeholder: "Optional description or admin notes"
-
field :position, type: :number, default: 0, hint: "Lower numbers appear first"
-
end
-
-
show do
-
sidebar do
-
panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
-
panel :status, title: "Status", fields: [ :disabled_at ]
-
end
-
-
main do
-
panel :details, title: "Details", fields: [ :name, :slug, :description, :position ]
-
panel :department, title: "Department", fields: [ :category ]
-
panel :interview_rounds, title: "Interview Rounds", association: :interview_rounds, limit: 10, display: :list
-
end
-
end
-
-
actions do
-
action :disable, method: :post, confirm: "Disable this round type?"
-
action :enable, method: :post
-
bulk_action :bulk_disable
-
bulk_action :bulk_enable
-
end
-
-
exportable :json, :csv
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for Job Listing admin management
-
#
-
# Provides CRUD operations with extraction status visibility and company/role filtering.
-
class JobListingResource < Admin::Base::Resource
-
model JobListing
-
portal :ops
-
section :jobs
-
-
index do
-
searchable :title
-
sortable :title, :created_at, :status, default: :created_at
-
paginate 25
-
-
stats do
-
stat :total, -> { JobListing.count }
-
stat :active, -> { JobListing.where(status: "active").count }, color: :green
-
stat :closed, -> { JobListing.where(status: "closed").count }, color: :slate
-
stat :with_description, -> { JobListing.where.not(description: [ nil, "" ]).count }, color: :blue
-
end
-
-
columns do
-
column :title
-
column :company, ->(jl) { jl.company&.name }
-
column :job_role, ->(jl) { jl.job_role&.title }
-
column :status, type: :label, label_color: ->(jl) {
-
case jl.status.to_sym
-
when :active then :green
-
when :closed then :slate
-
when :draft then :slate
-
else :gray
-
end
-
}
-
column :remote_type
-
column :created_at, ->(jl) { jl.created_at.strftime("%b %d, %Y") }
-
end
-
-
filters do
-
filter :status, type: :select, options: [
-
[ "All Statuses", "" ],
-
[ "Active", "active" ],
-
[ "Closed", "closed" ],
-
[ "Draft", "draft" ]
-
]
-
filter :remote_type, type: :select, label: "Remote", options: [
-
[ "All Types", "" ],
-
[ "Remote", "remote" ],
-
[ "Hybrid", "hybrid" ],
-
[ "On-site", "onsite" ]
-
]
-
filter :sort, type: :select, options: [
-
[ "Recently Added", "recent" ],
-
[ "Title (A-Z)", "title" ],
-
[ "Status", "status" ]
-
]
-
end
-
end
-
-
form do
-
section "Basic Information" do
-
field :title, required: true
-
field :url, type: :url, label: "Listing URL"
-
-
row cols: 2 do
-
field :status, type: :select, collection: [
-
[ "Active", "active" ],
-
[ "Closed", "closed" ],
-
[ "Draft", "draft" ]
-
]
-
field :remote_type, type: :select, collection: [
-
[ "Remote", "remote" ],
-
[ "Hybrid", "hybrid" ],
-
[ "On-site", "onsite" ]
-
]
-
end
-
end
-
-
section "Content" do
-
field :description, type: :textarea, rows: 6
-
field :requirements, type: :textarea, rows: 4
-
field :responsibilities, type: :textarea, rows: 4
-
field :benefits, type: :textarea, rows: 3
-
end
-
-
section "Compensation" do
-
row cols: 3 do
-
field :salary_min, type: :number, label: "Min Salary"
-
field :salary_max, type: :number, label: "Max Salary"
-
field :salary_currency, type: :select, collection: [
-
[ "USD", "USD" ],
-
[ "EUR", "EUR" ],
-
[ "GBP", "GBP" ]
-
]
-
end
-
field :location
-
end
-
end
-
-
show do
-
sidebar do
-
panel :status, title: "Status", fields: [ :status, :remote_type ]
-
panel :salary, title: "Compensation", fields: [ :salary_min, :salary_max, :salary_currency, :location ]
-
panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
-
end
-
-
main do
-
panel :info, title: "Listing Info", fields: [ :title, :url ]
-
panel :content, title: "Description", fields: [ :description ]
-
panel :requirements, title: "Requirements", fields: [ :requirements, :responsibilities ]
-
panel :benefits, title: "Benefits", fields: [ :benefits ]
-
panel :scraping, title: "Scraping Attempts", association: :scraping_attempts, limit: 5, display: :list
-
end
-
end
-
-
actions do
-
action :disable, method: :post, confirm: "Disable this job listing?", unless: ->(jl) { jl.status == "closed" }
-
action :enable, method: :post, if: ->(jl) { jl.status == "closed" }
-
end
-
-
exportable :json, :csv
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for JobRole admin management
-
#
-
# Provides CRUD operations with search, filtering, and merge functionality.
-
class JobRoleResource < Admin::Base::Resource
-
model JobRole
-
portal :ops
-
section :content
-
-
index do
-
searchable :title, :description
-
sortable :title, :created_at, default: :title
-
paginate 30
-
-
stats do
-
stat :total, -> { JobRole.count }
-
stat :with_listings, -> { JobRole.joins(:job_listings).distinct.count }, color: :blue
-
stat :user_targets, -> { JobRole.joins(:user_target_job_roles).distinct.count }, color: :amber
-
end
-
-
columns do
-
column :title
-
column :category, ->(jr) { jr.category&.name }, type: :label, label_color: :blue
-
column :job_listings_count, ->(jr) { jr.job_listings.count }, header: "Listings"
-
column :applications_count, ->(jr) { jr.interview_applications.count }, header: "Apps"
-
end
-
-
filters do
-
filter :category_id, type: :select, label: "Category",
-
options: -> { Category.pluck(:name, :id) }
-
filter :sort, type: :select, options: [
-
[ "Title (A-Z)", "title" ],
-
[ "Recently Added", "recent" ]
-
]
-
end
-
end
-
-
form do
-
field :title, required: true, placeholder: "Job role title"
-
field :category_id, type: :searchable_select, label: "Category",
-
collection: "/admin/categories/autocomplete",
-
create_url: "/admin/categories"
-
field :description, type: :textarea, rows: 4
-
end
-
-
show do
-
section :details, fields: [ :title, :description, :created_at, :updated_at ]
-
section :category, fields: [ :category ]
-
section :job_listings, association: :job_listings, limit: 10
-
end
-
-
actions do
-
action :disable, method: :post, confirm: "Disable this job role?"
-
action :enable, method: :post
-
action :merge, type: :modal
-
bulk_action :bulk_disable
-
end
-
-
exportable :json, :csv
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for LLM API Log admin management
-
#
-
# Provides read-only access to LLM API call logs for debugging.
-
class LlmApiLogResource < Admin::Base::Resource
-
model ::Ai::LlmApiLog
-
portal :ai
-
section :logs
-
-
index do
-
sortable :created_at, default: :created_at, direction: :desc
-
paginate 30
-
-
stats do
-
stat :total_7d, -> { ::Ai::LlmApiLog.recent_period(7).count }
-
stat :success, -> { ::Ai::LlmApiLog.recent_period(7).where(status: :success).count }, color: :green
-
stat :failed, -> { ::Ai::LlmApiLog.recent_period(7).where(status: :failed).count }, color: :red
-
stat :avg_latency, -> { "#{::Ai::LlmApiLog.recent_period(7).where.not(latency_ms: nil).average(:latency_ms).to_f.round(0)}ms" }, color: :blue
-
end
-
-
columns do
-
column :operation_type, ->(log) { log.operation_type&.humanize }, header: "Operation"
-
column :provider
-
column :model
-
column :status, type: :label, label_color: ->(log) {
-
case log.status.to_sym
-
when :success then :green
-
when :failed then :red
-
when :pending then :amber
-
else :gray
-
end
-
}
-
column :latency, ->(log) { log.latency_ms ? "#{log.latency_ms}ms" : "—" }
-
column :tokens, ->(log) { "#{log.input_tokens || 0} / #{log.output_tokens || 0}" }, header: "In/Out Tokens"
-
column :created_at, ->(log) { log.created_at.strftime("%b %d, %H:%M:%S") }, sortable: true
-
end
-
-
filters do
-
filter :provider, type: :select, options: [
-
[ "All Providers", "" ],
-
[ "OpenAI", "openai" ],
-
[ "Anthropic", "anthropic" ],
-
[ "Google", "google" ]
-
]
-
filter :status, type: :select, options: [
-
[ "All Statuses", "" ],
-
[ "Success", "success" ],
-
[ "Failed", "failed" ],
-
[ "Pending", "pending" ]
-
]
-
filter :operation_type, type: :select, label: "Operation", options: [
-
[ "All Operations", "" ],
-
[ "Job Extraction", "job_extraction" ],
-
[ "Chat Completion", "chat_completion" ],
-
[ "Memory Extraction", "memory_extraction" ]
-
]
-
filter :date_from, type: :date, label: "From Date"
-
filter :date_to, type: :date, label: "To Date"
-
end
-
end
-
-
show do
-
sidebar do
-
panel :meta, title: "Request Info", fields: [ :operation_type, :provider, :model, :status ]
-
panel :performance, title: "Performance", fields: [ :latency_ms, :input_tokens, :output_tokens, :estimated_cost_cents ]
-
panel :timestamps, title: "Timestamps", fields: [ :created_at ]
-
panel :error, title: "Error Details", fields: [ :error_type, :error_message ]
-
end
-
-
main do
-
panel :loggable, title: "Source", fields: [ :loggable ]
-
panel :prompt, title: "Prompt/Input", fields: [ :llm_prompt ]
-
panel :provider_request_raw, title: "Provider Request (raw)", render: :llm_provider_request_raw
-
panel :provider_response_raw, title: "Provider Response (raw)", render: :llm_provider_response_raw
-
panel :provider_error_response_raw, title: "Provider Error Response (raw)", render: :llm_provider_error_response_raw
-
panel :request, title: "Request Payload", fields: [ :request_payload ]
-
panel :response, title: "Response Payload", fields: [ :response_payload ]
-
end
-
end
-
-
exportable :json
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for LLM Prompt admin management
-
#
-
# Provides CRUD operations with activate and duplicate actions.
-
class LlmPromptResource < Admin::Base::Resource
-
model ::Ai::LlmPrompt
-
portal :ai
-
section :llm
-
-
index do
-
searchable :name, :description
-
sortable :name, :type, :version, :created_at, default: :type
-
paginate 20
-
-
stats do
-
stat :total, -> { ::Ai::LlmPrompt.count }
-
stat :active, -> { ::Ai::LlmPrompt.where(active: true).count }, color: :green
-
stat :inactive, -> { ::Ai::LlmPrompt.where(active: false).count }, color: :slate
-
end
-
-
columns do
-
column :name
-
column :type, ->(p) { p.type.demodulize.titleize }
-
column :version
-
column :active, type: :toggle, toggle_field: :active
-
end
-
-
filters do
-
filter :prompt_type, type: :select, label: "Type", options: [
-
[ "All Types", "" ],
-
[ "Job Extraction", "job_extraction" ],
-
[ "Email Extraction", "email_extraction" ],
-
[ "Resume Extraction", "resume_extraction" ],
-
[ "Assistant System", "assistant_system" ],
-
[ "Thread Summary", "assistant_thread_summary" ],
-
[ "Memory Proposal", "assistant_memory_proposal" ]
-
]
-
filter :active, type: :select, options: [
-
[ "All", "" ],
-
[ "Active Only", "true" ],
-
[ "Inactive Only", "false" ]
-
]
-
filter :sort, type: :select, options: [
-
[ "By Type", "type" ],
-
[ "By Name", "name" ],
-
[ "By Version", "version" ],
-
[ "Recently Added", "recent" ]
-
]
-
end
-
end
-
-
form do
-
section "Basic Information" do
-
field :name, required: true
-
field :description, type: :textarea, rows: 3
-
-
row cols: 2 do
-
field :version, type: :number, min: 1
-
field :active, type: :toggle
-
end
-
end
-
-
section "Prompt Template" do
-
field :system_prompt, type: :markdown, rows: 10, help: "System prompt for the LLM. This is used to instruct the LLM on how to behave and what to do."
-
field :prompt_template, type: :markdown, rows: 20,
-
help: "Use {{variable_name}} for template variables"
-
end
-
end
-
-
show do
-
sidebar do
-
panel :metadata, title: "Metadata", fields: [ :type, :version, :active ]
-
panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
-
end
-
-
main do
-
panel :info, title: "Information", fields: [ :name, :description ]
-
panel :system_prompt, title: "System Prompt", fields: [ :system_prompt ]
-
panel :template, title: "Prompt Template", render: :prompt_template_preview
-
panel :api_logs, title: "Recent API Logs",
-
association: :llm_api_logs,
-
limit: 10,
-
display: :table,
-
columns: [ :provider, :model, :status, :latency_ms, :created_at ],
-
link_to: :internal_developer_ai_llm_api_log_path
-
end
-
end
-
-
actions do
-
action :activate, method: :post, label: "Activate",
-
confirm: "This will deactivate all other prompts of the same type.",
-
unless: ->(p) { p.active? }
-
action :duplicate, method: :post, label: "Duplicate"
-
end
-
-
exportable :json
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for LLM Provider Config admin management
-
#
-
# Provides full CRUD for LLM provider configurations with test action.
-
class LlmProviderConfigResource < Admin::Base::Resource
-
model LlmProviderConfig
-
portal :ai
-
section :llm
-
-
index do
-
searchable :name, :llm_model
-
sortable :name, :priority, :provider_type, default: :priority
-
paginate 20
-
-
stats do
-
stat :total, -> { LlmProviderConfig.count }
-
stat :enabled, -> { LlmProviderConfig.where(enabled: true).count }, color: :green
-
stat :disabled, -> { LlmProviderConfig.where(enabled: false).count }, color: :slate
-
end
-
-
columns do
-
column :name
-
column :provider_type, header: "Provider"
-
column :llm_model, header: "Model"
-
column :priority
-
column :enabled, type: :toggle, toggle_field: :enabled
-
column :ready, ->(pc) { pc.ready? ? "Ready" : "Not Ready" }
-
end
-
-
filters do
-
filter :enabled, type: :select, options: [
-
[ "All", "" ],
-
[ "Enabled", "true" ],
-
[ "Disabled", "false" ]
-
]
-
filter :provider_type, type: :select, label: "Provider", options: [
-
[ "All Providers", "" ],
-
[ "OpenAI", "openai" ],
-
[ "Anthropic", "anthropic" ],
-
[ "Google", "google" ]
-
]
-
filter :sort, type: :select, options: [
-
[ "Priority", "priority" ],
-
[ "Name (A-Z)", "name" ],
-
[ "Provider", "provider_type" ]
-
]
-
end
-
end
-
-
form do
-
section "Provider Details" do
-
field :name, required: true
-
-
row cols: 2 do
-
field :provider_type, type: :select, required: true, collection: [
-
[ "OpenAI", "openai" ],
-
[ "Anthropic", "anthropic" ],
-
[ "Google", "google" ]
-
]
-
field :llm_model, required: true, label: "Model", placeholder: "e.g., gpt-4, claude-3-opus"
-
end
-
-
field :api_endpoint, type: :url, label: "API Endpoint", help: "Optional custom API endpoint"
-
end
-
-
section "Configuration" do
-
row cols: 3 do
-
field :priority, type: :number, help: "Lower = higher priority"
-
field :max_tokens, type: :number
-
field :temperature, type: :number
-
end
-
-
field :enabled, type: :toggle
-
end
-
-
section "Advanced Settings" do
-
field :settings, type: :json, help: "Additional provider-specific settings as JSON"
-
end
-
end
-
-
show do
-
sidebar do
-
panel :config, title: "Configuration", fields: [ :provider_type, :llm_model, :priority, :enabled ]
-
panel :params, title: "Parameters", fields: [ :max_tokens, :temperature ]
-
panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
-
end
-
-
main do
-
panel :details, title: "Provider Details", fields: [ :name, :api_endpoint ]
-
panel :settings, title: "Advanced Settings", fields: [ :settings ]
-
end
-
end
-
-
actions do
-
action :test_provider, method: :post, label: "Test Provider",
-
if: ->(pc) { pc.enabled? }
-
action :enable, method: :post, unless: ->(pc) { pc.enabled? }
-
action :disable, method: :post, if: ->(pc) { pc.enabled? }
-
end
-
-
exportable :json
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for Scraping Attempt admin management
-
#
-
# Provides observability into the scraping pipeline with event timeline.
-
class ScrapingAttemptResource < Admin::Base::Resource
-
model ScrapingAttempt
-
portal :ops
-
section :scraping
-
-
index do
-
sortable :created_at, :duration_seconds, default: :created_at
-
paginate 30
-
-
stats do
-
stat :total_7d, -> { ScrapingAttempt.recent_period(7).count }
-
stat :completed, -> { ScrapingAttempt.recent_period(7).where(status: :completed).count }, color: :green
-
stat :failed, -> { ScrapingAttempt.recent_period(7).where(status: :failed).count }, color: :red
-
stat :pending, -> { ScrapingAttempt.where(status: %w[pending fetching extracting]).count }, color: :amber
-
end
-
-
columns do
-
column :job_listing, ->(sa) { sa.job_listing&.title&.truncate(40) }
-
column :status, type: :label, label_color: ->(sa) {
-
case sa.status.to_sym
-
when :pending then :amber
-
when :fetching then :yellow
-
when :extracting then :amber
-
when :completed then :green
-
when :failed then :red
-
when :retrying then :indigo
-
when :dead_letter then :slate
-
when :manual then :purple
-
else :gray
-
end
-
}
-
column :domain
-
column :extraction_method
-
column :duration, ->(sa) { sa.duration_seconds ? "#{sa.duration_seconds}s" : "—" }
-
column :created_at, ->(sa) { sa.created_at.strftime("%b %d, %H:%M") }
-
end
-
-
filters do
-
filter :status, type: :select, options: [
-
[ "All Statuses", "" ],
-
[ "Pending", "pending" ],
-
[ "Fetching", "fetching" ],
-
[ "Extracting", "extracting" ],
-
[ "Completed", "completed" ],
-
[ "Failed", "failed" ]
-
]
-
filter :extraction_method, type: :select, label: "Method", options: [
-
[ "All Methods", "" ],
-
[ "LLM", "llm" ],
-
[ "Structured", "structured" ],
-
[ "Fallback", "fallback" ]
-
]
-
filter :date_from, type: :date, label: "From Date"
-
filter :date_to, type: :date, label: "To Date"
-
end
-
end
-
-
show do
-
sidebar do
-
panel :status, title: "Status", fields: [ :status, :extraction_method, :provider ]
-
panel :timing, title: "Timing", fields: [ :duration_seconds, :created_at, :updated_at ]
-
panel :error, title: "Error", fields: [ :failed_step, :error_message ]
-
panel :job, title: "Job Listing",
-
association: :job_listing,
-
link_to: :internal_developer_ops_job_listing_path
-
end
-
-
main do
-
panel :events, title: "Scraping Events",
-
association: :scraping_events,
-
limit: 50,
-
display: :table,
-
columns: [ :step_order, :event_type_display, :status, :duration_ms, :created_at ],
-
link_to: :internal_developer_ops_scraping_event_path
-
panel :html_logs, title: "HTML Scraping Logs",
-
association: :html_scraping_logs,
-
limit: 10,
-
display: :table,
-
columns: [ :domain, :status, :extraction_rate, :duration_ms, :created_at ],
-
link_to: :internal_developer_ops_html_scraping_log_path
-
panel :logs, title: "LLM API Logs",
-
association: :llm_api_logs,
-
limit: 10,
-
display: :table,
-
columns: [ :provider, :model, :status, :latency_ms, :created_at ],
-
link_to: :internal_developer_ai_llm_api_log_path
-
end
-
end
-
-
actions do
-
action :mark_failed, method: :post, label: "Mark Failed",
-
confirm: "Mark this attempt as failed?",
-
unless: ->(sa) { %w[completed failed].include?(sa.status) }
-
action :retry_attempt, method: :post, label: "Retry",
-
if: ->(sa) { sa.status == "failed" }
-
collection_action :cleanup_stuck, method: :post, label: "Cleanup Stuck Attempts"
-
end
-
-
exportable :json
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for Scraping Event admin management
-
#
-
# Provides read-only access to scraping events for debugging.
-
class ScrapingEventResource < Admin::Base::Resource
-
model ScrapingEvent
-
portal :ops
-
section :scraping
-
-
index do
-
sortable :created_at, default: :created_at
-
paginate 50
-
-
stats do
-
stat :total, -> { ScrapingEvent.count }
-
stat :success, -> { ScrapingEvent.where(status: :success).count }, color: :green
-
stat :failed, -> { ScrapingEvent.where(status: :failed).count }, color: :red
-
stat :skipped, -> { ScrapingEvent.where(status: :skipped).count }, color: :slate
-
end
-
-
columns do
-
column :step_name, ->(e) { e.event_type_display }, header: "Step"
-
column :status, type: :label, label_color: ->(e) {
-
case e.status.to_sym
-
when :started then :amber
-
when :success then :green
-
when :failed then :red
-
when :skipped then :purple
-
else :gray
-
end
-
}
-
column :duration, ->(e) { e.duration_ms ? "#{e.duration_ms}ms" : "—" }
-
column :scraping_attempt, ->(e) { "##{e.scraping_attempt_id}" }, header: "Attempt"
-
column :created_at, ->(e) { e.created_at.strftime("%b %d, %H:%M:%S") }
-
end
-
-
filters do
-
filter :status, type: :select, options: [
-
[ "All Statuses", "" ],
-
[ "Started", "started" ],
-
[ "Success", "success" ],
-
[ "Failed", "failed" ],
-
[ "Skipped", "skipped" ]
-
]
-
filter :step_name, type: :text, placeholder: "Step name..."
-
end
-
end
-
-
show do
-
sidebar do
-
panel :event, title: "Event Info", fields: [ :step_name, :status, :duration_ms ]
-
panel :timestamps, title: "Timestamps", fields: [ :started_at, :completed_at, :created_at ]
-
panel :attempt, title: "Scraping Attempt",
-
association: :scraping_attempt,
-
link_to: :internal_developer_ops_scraping_attempt_path
-
end
-
-
main do
-
panel :error, title: "Error Details", fields: [ :error_type, :error_message ]
-
panel :input, title: "Input Payload", fields: [ :input_payload ]
-
panel :output, title: "Output Payload", fields: [ :output_payload ]
-
panel :metadata, title: "Metadata", fields: [ :metadata ]
-
end
-
end
-
-
exportable :json
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for application settings admin management
-
#
-
# Provides toggle-based management for boolean feature flags.
-
class SettingResource < Admin::Base::Resource
-
model ::Setting
-
portal :ops
-
section :settings
-
-
index do
-
searchable :name
-
sortable :name, :updated_at, default: :name
-
paginate 50
-
-
stats do
-
stat :total, -> { ::Setting.count }
-
stat :enabled, -> { ::Setting.where(value: true).count }, color: :green
-
stat :disabled, -> { ::Setting.where(value: false).count }, color: :slate
-
end
-
-
columns do
-
column :name, ->(s) { s.name.humanize }
-
column :value, type: :toggle, toggle_field: :value
-
column :updated_at, ->(s) { s.updated_at&.strftime("%b %d, %H:%M") }
-
end
-
-
filters do
-
filter :value, type: :select, label: "Status", options: [
-
[ "All", "" ],
-
[ "Enabled", "true" ],
-
[ "Disabled", "false" ]
-
]
-
end
-
end
-
-
form do
-
section "Setting Configuration" do
-
field :name, type: :searchable_select, required: true,
-
collection: ::Setting::AVAILABLE_SETTINGS.map { |s| [ s.humanize, s ] },
-
placeholder: "Select a setting...",
-
help: "Choose from available settings"
-
-
field :value, type: :toggle, label: "Enabled",
-
help: "Toggle to enable or disable this feature"
-
end
-
end
-
-
show do
-
sidebar do
-
panel :status, title: "Status", fields: [ :value ]
-
panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
-
end
-
-
main do
-
panel :setting, title: "Setting", fields: [ :name ]
-
end
-
end
-
-
actions do
-
action :toggle, method: :post, label: "Toggle"
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for SkillTag admin management
-
#
-
# Provides CRUD operations with search, filtering, and merge functionality.
-
class SkillTagResource < Admin::Base::Resource
-
model SkillTag
-
portal :ops
-
section :content
-
-
index do
-
searchable :name
-
sortable :name, :created_at, default: :name
-
paginate 50
-
-
stats do
-
stat :total, -> { SkillTag.count }
-
stat :with_users, -> { SkillTag.joins(:user_skills).distinct.count }, color: :blue
-
stat :with_resumes, -> { SkillTag.joins(:resume_skills).distinct.count }, color: :green
-
end
-
-
columns do
-
column :name
-
column :user_skills_count, ->(st) { st.user_skills.count }, header: "User Skills"
-
column :resume_skills_count, ->(st) { st.resume_skills.count }, header: "Resume Skills"
-
end
-
-
filters do
-
filter :sort, type: :select, options: [
-
[ "Name (A-Z)", "name" ],
-
[ "Recently Added", "recent" ],
-
[ "Most Used", "usage" ]
-
]
-
end
-
end
-
-
form do
-
field :name, required: true, placeholder: "Skill tag name"
-
end
-
-
show do
-
section :details, fields: [ :name, :created_at, :updated_at ]
-
section :user_skills, association: :user_skills, limit: 20
-
section :resume_skills, association: :resume_skills, limit: 20
-
end
-
-
actions do
-
action :disable, method: :post, confirm: "Disable this skill tag?"
-
action :enable, method: :post
-
action :merge, type: :modal
-
bulk_action :bulk_merge, label: "Merge Selected"
-
end
-
-
exportable :json, :csv
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for Support Ticket admin management
-
#
-
# Provides listing and status management for support tickets.
-
class SupportTicketResource < Admin::Base::Resource
-
model SupportTicket
-
portal :ops
-
section :support
-
-
index do
-
searchable :name, :email, :subject, :message
-
sortable :created_at, :name, :email, default: :created_at
-
paginate 30
-
-
stats do
-
stat :total, -> { SupportTicket.count }
-
stat :open, -> { SupportTicket.open.count }, color: :amber
-
stat :in_progress, -> { SupportTicket.in_progress.count }, color: :blue
-
stat :resolved, -> { SupportTicket.resolved.count }, color: :green
-
stat :closed, -> { SupportTicket.closed.count }, color: :slate
-
end
-
-
columns do
-
column :subject
-
column :name
-
column :email
-
column :status
-
column :user, ->(st) { st.user ? "Registered" : "Guest" }, header: "Type"
-
column :created_at, ->(st) { st.created_at.strftime("%b %d, %H:%M") }
-
end
-
-
filters do
-
filter :status, type: :select, options: [
-
["All Statuses", ""],
-
["Open", "open"],
-
["In Progress", "in_progress"],
-
["Resolved", "resolved"],
-
["Closed", "closed"]
-
]
-
filter :user_type, type: :select, label: "Sender", options: [
-
["All", ""],
-
["Registered Users", "registered"],
-
["Guests", "guest"]
-
]
-
filter :sort, type: :select, options: [
-
["Newest First", "recent"],
-
["Oldest First", "oldest"],
-
["Name (A-Z)", "name"]
-
]
-
end
-
end
-
-
form do
-
section "Ticket Status" do
-
field :status, type: :select, collection: [
-
["Open", "open"],
-
["In Progress", "in_progress"],
-
["Resolved", "resolved"],
-
["Closed", "closed"]
-
]
-
end
-
end
-
-
show do
-
sidebar do
-
panel :sender, title: "Sender", fields: [:name, :email, :user]
-
panel :status, title: "Status", fields: [:status]
-
panel :timestamps, title: "Timestamps", fields: [:created_at, :updated_at]
-
end
-
-
main do
-
panel :ticket, title: "Ticket Content", fields: [:subject, :message]
-
end
-
end
-
-
actions do
-
action :mark_in_progress, method: :post, label: "Start", if: ->(st) { st.status == "open" }
-
action :resolve, method: :post, if: ->(st) { st.status == "in_progress" }
-
action :close, method: :post, if: ->(st) { st.status == "resolved" }
-
action :reopen, method: :post, if: ->(st) { st.status == "closed" }
-
end
-
-
exportable :json, :csv
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for Synced Email admin management
-
#
-
# Provides viewing and manual matching of synced emails from Gmail.
-
# Includes signal extraction debugging information.
-
class SyncedEmailResource < Admin::Base::Resource
-
model SyncedEmail
-
portal :email
-
section :inbox
-
-
index do
-
searchable :subject, :from_email, :from_name
-
sortable :email_date, :subject, default: :email_date
-
paginate 30
-
-
stats do
-
stat :total, -> { SyncedEmail.count }
-
stat :pending, -> { SyncedEmail.where(status: :pending).count }, color: :amber
-
stat :processed, -> { SyncedEmail.where(status: :processed).count }, color: :green
-
stat :needs_review, -> { SyncedEmail.needs_review.count }, color: :red
-
stat :matched, -> { SyncedEmail.matched.count }, color: :blue
-
stat :pending_extraction, -> { SyncedEmail.where(extraction_status: "pending").count }, color: :amber
-
stat :extracted, -> { SyncedEmail.where(extraction_status: "completed").count }, color: :green
-
end
-
-
columns do
-
column :subject, ->(se) { se.subject&.truncate(50) }
-
column :from_email, header: "From"
-
column :user, ->(se) { se.user&.email_address }
-
column :status, type: :label, label_color: ->(se) {
-
case se.status.to_sym
-
when :pending then :amber
-
when :processed then :green
-
when :ignored then :slate
-
when :failed then :red
-
when :auto_ignored then :slate
-
else :gray
-
end
-
}
-
column :email_type, header: "Type"
-
column :extraction_status, type: :label, label_color: ->(se) {
-
case se.extraction_status.to_sym
-
when :pending then :amber
-
when :processing then :indigo
-
when :completed then :green
-
when :failed then :red
-
when :skipped then :purple
-
else :gray
-
end
-
}
-
column :matched, type: :label, label_color: ->(se) { se.interview_application_id? ? :green : :amber }
-
column :email_date, ->(se) { se.email_date&.strftime("%b %d, %H:%M") }
-
end
-
-
filters do
-
filter :status, type: :select, options: -> {
-
[ [ "All Statuses", "" ] ] + SyncedEmail::STATUSES.map { |s| [ s.to_s.humanize, s.to_s ] }
-
}
-
filter :email_type, type: :select, label: "Type", options: -> {
-
[ [ "All Types", "" ] ] + SyncedEmail::EMAIL_TYPES.map { |t| [ t.humanize, t ] }
-
}
-
filter :extraction_status, type: :select, label: "Extraction", options: -> {
-
[ [ "All", "" ] ] + SyncedEmail::EXTRACTION_STATUSES.map { |s| [ s.humanize, s ] }
-
}
-
filter :matched, type: :select, options: [
-
[ "All", "" ],
-
[ "Matched", "matched" ],
-
[ "Unmatched", "unmatched" ]
-
]
-
filter :sort, type: :select, options: [
-
[ "Newest First", "recent" ],
-
[ "Oldest First", "oldest" ],
-
[ "Subject", "subject" ]
-
]
-
end
-
end
-
-
form do
-
section "Email Matching" do
-
field :interview_application_id, type: :number, label: "Application ID"
-
field :email_type, type: :select, collection: [ [ "Unknown", "" ] ] + SyncedEmail::EMAIL_TYPES.map { |t| [ t.humanize, t ] }
-
field :status, type: :select, collection: [
-
[ "Pending", "pending" ],
-
[ "Processed", "processed" ],
-
[ "Ignored", "ignored" ],
-
[ "Failed", "failed" ],
-
[ "Auto Ignored", "auto_ignored" ]
-
]
-
end
-
-
section "Extraction Status" do
-
field :extraction_status, type: :select, collection: [
-
[ "Pending", "pending" ],
-
[ "Processing", "processing" ],
-
[ "Completed", "completed" ],
-
[ "Failed", "failed" ],
-
[ "Skipped", "skipped" ]
-
]
-
end
-
end
-
-
show do
-
sidebar do
-
panel :sender, title: "Sender", fields: [ :from_email, :from_name, :email_sender ]
-
panel :status, title: "Status", fields: [ :status, :email_type ]
-
panel :matching, title: "Matching", fields: [ :interview_application ]
-
panel :timestamps, title: "Dates", fields: [ :email_date, :created_at ]
-
panel :extraction, title: "Signal Extraction", fields: [
-
:extraction_status, :extraction_confidence, :extracted_at
-
]
-
end
-
-
main do
-
panel :email, title: "Email Content", fields: [ :subject, :body_snippet ]
-
panel :extracted_intelligence, title: "Extracted Intelligence", fields: [
-
:signal_company_name, :signal_company_website, :signal_company_careers_url, :signal_company_domain,
-
:signal_recruiter_name, :signal_recruiter_email, :signal_recruiter_title, :signal_recruiter_linkedin,
-
:signal_job_title, :signal_job_department, :signal_job_location, :signal_job_url, :signal_job_salary_hint
-
]
-
panel :actions_and_links, title: "Actions & Links", fields: [
-
:signal_action_links, :signal_suggested_actions
-
]
-
panel :raw_extraction, title: "Raw Extracted Data (JSON)", fields: [ :extracted_data ]
-
end
-
end
-
-
actions do
-
action :mark_processed, method: :post, if: ->(se) { se.status == "pending" }
-
action :mark_needs_review, method: :post, unless: ->(se) { se.pending? && se.interview_application_id.nil? }
-
action :ignore, method: :post, unless: ->(se) { se.ignored? }
-
action :trigger_extraction, method: :post, if: ->(se) { se.extraction_status.in?([ "pending", "failed" ]) }
-
end
-
-
exportable :json
-
-
# Custom action to trigger signal extraction
-
def trigger_extraction
-
ProcessSignalExtractionJob.perform_later(resource.id)
-
redirect_to show_path, notice: "Signal extraction queued"
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Admin
-
module Resources
-
# Resource definition for User admin management
-
#
-
# Provides read-only access to users with connected accounts and sync visibility.
-
class UserResource < Admin::Base::Resource
-
model User
-
portal :ops
-
section :users
-
-
index do
-
searchable :email_address, :name
-
sortable :name, :created_at, default: :created_at
-
paginate 30
-
-
stats do
-
stat :total, -> { User.count }
-
stat :with_gmail, -> { User.joins(:connected_accounts).where(connected_accounts: { provider: "google_oauth2" }).distinct.count }, color: :blue
-
stat :sync_enabled, -> { User.joins(:connected_accounts).where(connected_accounts: { provider: "google_oauth2", sync_enabled: true }).distinct.count }, color: :green
-
stat :admins, -> { User.where(is_admin: true).count }, color: :amber
-
end
-
-
columns do
-
column :email_address, header: "Email"
-
column :name
-
column :is_admin, ->(u) { u.is_admin? ? "Admin" : "User" }, header: "Role"
-
column :gmail_status, ->(u) {
-
account = u.connected_accounts.find_by(provider: "google_oauth2")
-
account ? (account.sync_enabled? ? "Syncing" : "Connected") : "Not Connected"
-
}, header: "Gmail"
-
column :created_at, ->(u) { u.created_at.strftime("%b %d, %Y") }
-
end
-
-
filters do
-
filter :role, type: :select, options: [
-
[ "All Users", "" ],
-
[ "Admins Only", "admin" ],
-
[ "Regular Users", "user" ]
-
]
-
filter :gmail_status, type: :select, label: "Gmail", options: [
-
[ "All", "" ],
-
[ "Connected", "connected" ],
-
[ "Not Connected", "not_connected" ],
-
[ "Sync Enabled", "sync_enabled" ]
-
]
-
filter :sort, type: :select, options: [
-
[ "Recently Joined", "recent" ],
-
[ "Name (A-Z)", "name" ],
-
[ "Most Emails", "email_count" ]
-
]
-
end
-
end
-
-
show do
-
sidebar do
-
panel :account, title: "Account", fields: [ :email_address, :is_admin, :email_verified_at ]
-
panel :billing, title: "Billing", fields: [ :billing_admin_access? ]
-
panel :timestamps, title: "Activity", fields: [ :created_at, :updated_at ]
-
end
-
-
main do
-
panel :profile, title: "Profile", fields: [ :name ]
-
panel :billing_debug, title: "Billing Debug", render: :billing_debug_snapshot
-
panel :connected_accounts, title: "Connected Accounts",
-
association: :connected_accounts,
-
display: :table,
-
columns: [ :provider, :sync_enabled, :created_at ],
-
link_to: :internal_developer_ops_connected_account_path
-
panel :threads, title: "Chat Threads",
-
association: :chat_threads,
-
limit: 5,
-
display: :list,
-
link_to: :internal_developer_assistant_thread_path
-
panel :applications, title: "Interview Applications",
-
association: :interview_applications,
-
limit: 10,
-
display: :list,
-
link_to: :internal_developer_ops_interview_application_path
-
panel :emails, title: "Recent Synced Emails",
-
association: :synced_emails,
-
limit: 10,
-
display: :table,
-
columns: [ :subject, :from_address, :synced_at ],
-
link_to: :internal_developer_ops_synced_email_path
-
end
-
end
-
-
actions do
-
action :resend_verification_email, method: :post, label: "Resend Verification Email",
-
confirm: "Send a new verification email to this user?",
-
unless: ->(u) { u.email_verified? }
-
action :grant_admin, method: :post, label: "Grant Admin Privileges",
-
confirm: "Grant admin privileges to this user? They will have full access to the developer portal.",
-
unless: ->(u) { u.admin? }
-
action :revoke_admin, method: :post, label: "Revoke Admin Privileges",
-
confirm: "Revoke admin privileges from this user?",
-
if: ->(u) { u.admin? }
-
action :grant_billing_admin_access, method: :post, label: "Grant Billing Admin Access",
-
confirm: "Grant Admin/Developer billing access (all features) to this user?",
-
unless: ->(u) { Billing::AdminAccessService.new(user: u).active? }
-
action :revoke_billing_admin_access, method: :post, label: "Revoke Billing Admin Access",
-
confirm: "Revoke Admin/Developer billing access from this user?",
-
if: ->(u) { Billing::AdminAccessService.new(user: u).active? }
-
end
-
-
exportable :json
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
AdminSuite.portal :ai do
-
1
label "AI Portal"
-
1
icon "cpu"
-
1
color :cyan
-
1
order 30
-
1
description "LLM & Machine Learning management"
-
-
1
dashboard do
-
1
row do
-
1
health_panel "LLM API",
-
span: 4,
-
status: lambda {
-
recent_logs = ::Ai::LlmApiLog.where("created_at > ?", 24.hours.ago)
-
total = recent_logs.count
-
successful = recent_logs.where(status: :success).count
-
then: 0
else: 0
success_rate = total > 0 ? (successful.to_f / total * 100).round : 100
-
-
then: 0
if total > 10 && success_rate < 80
-
else: 0
:critical
-
then: 0
elsif total > 10 && success_rate < 95
-
:degraded
-
else: 0
else
-
:healthy
-
end
-
},
-
metrics: lambda {
-
recent_logs = ::Ai::LlmApiLog.where("created_at > ?", 24.hours.ago)
-
total = recent_logs.count
-
successful = recent_logs.where(status: :success).count
-
failed = recent_logs.where(status: :failed).count
-
then: 0
else: 0
avg_latency = recent_logs.where(status: :success).average(:latency_ms)&.round || 0
-
total_cost_cents = recent_logs.sum(:estimated_cost_cents) || 0
-
total_cost = (total_cost_cents / 100.0).round(2)
-
then: 0
else: 0
success_rate = total > 0 ? (successful.to_f / total * 100).round : 100
-
-
{
-
"24h calls" => total,
-
"success rate" => "#{success_rate}%",
-
"avg latency" => "#{avg_latency}ms",
-
"24h cost" => "$#{total_cost}",
-
"failed" => failed
-
}
-
}
-
-
1
chart_panel "API Calls (7 days)",
-
span: 4,
-
data: lambda {
-
(0..6).map do |i|
-
date = i.days.ago.to_date
-
count = ::Ai::LlmApiLog.where(created_at: date.beginning_of_day..date.end_of_day).count
-
{ label: date.strftime("%a"), value: count }
-
end.reverse
-
}
-
-
1
chart_panel "Cost (7 days, cents)",
-
span: 4,
-
data: lambda {
-
(0..6).map do |i|
-
date = i.days.ago.to_date
-
cost_cents = ::Ai::LlmApiLog.where(created_at: date.beginning_of_day..date.end_of_day).sum(:estimated_cost_cents) || 0
-
{ label: date.strftime("%a"), value: cost_cents }
-
end.reverse
-
}
-
end
-
-
1
row do
-
1
stat_panel "LLM Prompts", -> { ::Ai::LlmPrompt.count }, span: 2, variant: :mini, color: :slate
-
1
stat_panel "Active Prompts", -> { ::Ai::LlmPrompt.where(active: true).count }, span: 2, variant: :mini, color: :green
-
1
stat_panel "Provider Configs", -> { ::LlmProviderConfig.count }, span: 2, variant: :mini, color: :slate
-
1
stat_panel "Enabled Providers", -> { ::LlmProviderConfig.where(enabled: true).count }, span: 2, variant: :mini, color: :cyan
-
1
stat_panel "API Logs", -> { ::Ai::LlmApiLog.count }, span: 2, variant: :mini, color: :slate
-
1
stat_panel "Resources", -> { Admin::Base::Resource.resources_for_portal(:ai).count }, span: 2, variant: :mini, color: :cyan
-
end
-
-
1
row do
-
1
cards_panel "LLM Management",
-
span: 12,
-
resources: [
-
{ resource_name: "llm_prompts", label: "LLM Prompts", description: "Manage prompt templates for AI models", icon: "scroll-text", count: -> { ::Ai::LlmPrompt.count } },
-
{ resource_name: "llm_provider_configs", label: "Provider Configs", description: "Configure AI providers (OpenAI, Anthropic, etc)", icon: "sliders-horizontal", count: -> { ::LlmProviderConfig.count } },
-
{ resource_name: "llm_api_logs", label: "LLM API Logs", description: "Inspect API calls and errors", icon: "activity", count: -> { ::Ai::LlmApiLog.count } }
-
]
-
end
-
-
1
row do
-
1
recent_panel "Recent API Calls",
-
span: 6,
-
scope: -> { ::Ai::LlmApiLog.order(created_at: :desc).limit(8) },
-
view_all_path: ->(view) { view.resources_path(portal: :ai, resource_name: "llm_api_logs") }
-
-
1
recent_panel "Recently Updated Prompts",
-
span: 6,
-
scope: -> { ::Ai::LlmPrompt.order(updated_at: :desc).limit(8) },
-
view_all_path: ->(view) { view.resources_path(portal: :ai, resource_name: "llm_prompts") }
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
AdminSuite.portal :assistant do
-
1
label "Assistant Portal"
-
1
icon "message-circle"
-
1
color :violet
-
1
order 40
-
1
description "Chat, Tools & Memory management"
-
-
1
dashboard do
-
1
row do
-
1
health_panel "Assistant System",
-
span: 4,
-
status: lambda {
-
recent_executions = ::Assistant::ToolExecution.where("created_at > ?", 24.hours.ago)
-
total = recent_executions.count
-
successful = recent_executions.where(status: :completed).count
-
pending = ::Assistant::ToolExecution.where(status: :pending_approval).count
-
failed = recent_executions.where(status: :failed).count
-
then: 0
else: 0
success_rate = total > 0 ? (successful.to_f / total * 100).round : 100
-
-
then: 0
if failed > 10
-
else: 0
:critical
-
then: 0
elsif pending > 20 || (total > 10 && success_rate < 70)
-
:degraded
-
else: 0
else
-
:healthy
-
end
-
},
-
metrics: lambda {
-
recent_threads = ::Assistant::ChatThread.where("created_at > ?", 24.hours.ago)
-
recent_executions = ::Assistant::ToolExecution.where("created_at > ?", 24.hours.ago)
-
-
total = recent_executions.count
-
successful = recent_executions.where(status: :completed).count
-
pending = ::Assistant::ToolExecution.where(status: :pending_approval).count
-
failed = recent_executions.where(status: :failed).count
-
then: 0
else: 0
success_rate = total > 0 ? (successful.to_f / total * 100).round : 100
-
-
{
-
"24h threads" => recent_threads.count,
-
"24h tool runs" => total,
-
"success rate" => "#{success_rate}%",
-
"pending approval" => pending,
-
"failed" => failed
-
}
-
}
-
-
1
chart_panel "Threads (7 days)",
-
span: 4,
-
data: lambda {
-
(0..6).map do |i|
-
date = i.days.ago.to_date
-
count = ::Assistant::ChatThread.where(created_at: date.beginning_of_day..date.end_of_day).count
-
{ label: date.strftime("%a"), value: count }
-
end.reverse
-
}
-
-
1
chart_panel "Tool Runs (7 days)",
-
span: 4,
-
data: lambda {
-
(0..6).map do |i|
-
date = i.days.ago.to_date
-
count = ::Assistant::ToolExecution.where(created_at: date.beginning_of_day..date.end_of_day).count
-
{ label: date.strftime("%a"), value: count }
-
end.reverse
-
}
-
end
-
-
1
row do
-
1
stat_panel "Threads", -> { ::Assistant::ChatThread.count }, span: 2, variant: :mini, color: :slate
-
1
stat_panel "Open", -> { ::Assistant::ChatThread.where(status: "open").count }, span: 2, variant: :mini, color: :green
-
1
stat_panel "Tool Runs", -> { ::Assistant::ToolExecution.count }, span: 2, variant: :mini, color: :slate
-
1
stat_panel "Active Tools", -> { ::Assistant::Tool.where(enabled: true).count }, span: 2, variant: :mini, color: :green
-
1
stat_panel "Memories", -> { ::Assistant::Memory::UserMemory.count }, span: 2, variant: :mini, color: :cyan
-
1
stat_panel "Resources", -> { Admin::Base::Resource.resources_for_portal(:assistant).count }, span: 2, variant: :mini, color: :violet
-
end
-
-
1
row do
-
1
recent_panel "Recent Threads",
-
span: 6,
-
scope: -> { ::Assistant::ChatThread.includes(:user).order(created_at: :desc).limit(8) },
-
view_all_path: ->(view) { view.resources_path(portal: :assistant, resource_name: "assistant_threads") }
-
-
1
recent_panel "Recent Tool Runs",
-
span: 4,
-
scope: -> { ::Assistant::ToolExecution.order(created_at: :desc).limit(8) },
-
view_all_path: ->(view) { view.resources_path(portal: :assistant, resource_name: "assistant_tool_executions") }
-
-
1
stat_panel "Pending Approvals",
-
-> { ::Assistant::ToolExecution.where(status: :pending_approval).count },
-
span: 2,
-
variant: :mini,
-
color: :amber
-
end
-
-
1
row do
-
1
cards_panel "Assistant Management",
-
span: 12,
-
resources: begin
-
items = [
-
1
{ resource_name: "assistant_tools", label: "Tools", description: "Manage tool definitions", icon: "wrench", count: -> { ::Assistant::Tool.count } },
-
{ resource_name: "assistant_threads", label: "Threads", description: "Monitor ongoing conversations", icon: "message-square", count: -> { ::Assistant::ChatThread.count } },
-
{ resource_name: "assistant_turns", label: "Turns", description: "Conversation turns", icon: "repeat", count: -> { ::Assistant::Turn.count } }
-
]
-
-
# `Assistant::Event` is optional; some deployments don't ship it.
-
1
then: 0
else: 1
if defined?(::Assistant::Event)
-
items << { resource_name: "assistant_events", label: "Events", description: "System events", icon: "clock", count: -> { ::Assistant::Event.count } }
-
end
-
-
1
items
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
AdminSuite.portal :email do
-
1
label "Email Portal"
-
1
icon "inbox"
-
1
color :emerald
-
1
order 20
-
1
description "Synced emails + Signals pipeline timeline"
-
-
1
dashboard do
-
1
row do
-
1
health_panel "Signals Pipeline (24h)",
-
span: 4,
-
status: lambda {
-
recent_runs = Signals::EmailPipelineRun.where("created_at > ?", 24.hours.ago)
-
total = recent_runs.count
-
successful = recent_runs.where(status: :success).count
-
failed = recent_runs.where(status: :failed).count
-
running = recent_runs.where(status: :started).count
-
then: 0
else: 0
success_rate = total.positive? ? (successful.to_f / total * 100).round : 0
-
-
then: 0
if failed > 10 || (total > 20 && success_rate < 80)
-
else: 0
:critical
-
then: 0
elsif failed.positive? || (total > 20 && success_rate < 95) || running > 20
-
:degraded
-
else: 0
else
-
:healthy
-
end
-
},
-
metrics: lambda {
-
recent_runs = Signals::EmailPipelineRun.where("created_at > ?", 24.hours.ago)
-
total = recent_runs.count
-
successful = recent_runs.where(status: :success).count
-
failed = recent_runs.where(status: :failed).count
-
running = recent_runs.where(status: :started).count
-
-
then: 0
else: 0
success_rate = total.positive? ? (successful.to_f / total * 100).round : 0
-
then: 0
else: 0
avg_duration = recent_runs.where.not(duration_ms: nil).average(:duration_ms)&.round || 0
-
-
{
-
"24h runs" => total,
-
"success rate" => "#{success_rate}%",
-
"failed" => failed,
-
"running" => running,
-
"avg duration" => "#{avg_duration}ms"
-
}
-
}
-
-
1
stat_panel "Synced Emails",
-
-> { SyncedEmail.count },
-
span: 4,
-
color: :green
-
-
1
stat_panel "Pipeline Runs (24h)",
-
-> { Signals::EmailPipelineRun.where("created_at > ?", 24.hours.ago).count },
-
span: 4,
-
color: :cyan
-
end
-
-
1
row do
-
1
stat_panel "Matched", -> { SyncedEmail.matched.count }, span: 2, variant: :mini, color: :green
-
1
stat_panel "Unmatched", -> { SyncedEmail.unmatched.count }, span: 2, variant: :mini, color: :amber
-
1
stat_panel "Needs Review", -> { SyncedEmail.needs_review.count }, span: 2, variant: :mini, color: :red
-
1
stat_panel "Runs (24h)", -> { Signals::EmailPipelineRun.where("created_at > ?", 24.hours.ago).count }, span: 2, variant: :mini, color: :slate
-
1
stat_panel "Events (24h)", -> { Signals::EmailPipelineEvent.where("created_at > ?", 24.hours.ago).count }, span: 2, variant: :mini, color: :slate
-
1
stat_panel "Resources", -> { Admin::Base::Resource.resources_for_portal(:email).count }, span: 2, variant: :mini, color: :emerald
-
end
-
-
1
row do
-
1
recent_panel "Recent Emails",
-
span: 7,
-
scope: -> { SyncedEmail.order(email_date: :desc).limit(8) },
-
view_all_path: ->(view) { view.resources_path(portal: :email, resource_name: "synced_emails") }
-
-
1
recent_panel "Recent Pipeline Runs",
-
span: 5,
-
scope: -> { Signals::EmailPipelineRun.order(created_at: :desc).limit(8) },
-
view_all_path: ->(view) { view.resources_path(portal: :email, resource_name: "email_pipeline_runs") }
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
AdminSuite.portal :ops do
-
1
label "Ops Portal"
-
1
icon "settings"
-
1
color :amber
-
1
order 10
-
1
description "Content, Users, Email & Scraping Management"
-
-
1
dashboard do
-
1
row do
-
1
health_panel "Scraping Pipeline",
-
span: 4,
-
status: lambda {
-
recent = ScrapingAttempt.where("created_at > ?", 24.hours.ago)
-
total = recent.count
-
completed = recent.where(status: :completed).count
-
stuck = recent.where(status: :processing).where("updated_at < ?", 30.minutes.ago).count
-
then: 0
else: 0
rate = total > 0 ? (completed.to_f / total * 100).round : 0
-
-
then: 0
if stuck > 5 || (total > 10 && rate < 50)
-
else: 0
:critical
-
then: 0
elsif stuck > 0 || (total > 10 && rate < 80)
-
:degraded
-
else: 0
else
-
:healthy
-
end
-
},
-
metrics: lambda {
-
recent = ScrapingAttempt.where("created_at > ?", 24.hours.ago)
-
total = recent.count
-
completed = recent.where(status: :completed).count
-
failed = recent.where(status: :failed).count
-
stuck = recent.where(status: :processing).where("updated_at < ?", 30.minutes.ago).count
-
then: 0
else: 0
rate = total > 0 ? (completed.to_f / total * 100).round : 0
-
{
-
"24h attempts" => total,
-
"success rate" => "#{rate}%",
-
"failed" => failed,
-
"stuck" => stuck
-
}
-
}
-
-
1
chart_panel "Scraping (7 days)",
-
span: 4,
-
data: lambda {
-
(0..6).map do |i|
-
date = i.days.ago.to_date
-
count = ScrapingAttempt.where(created_at: date.beginning_of_day..date.end_of_day).count
-
{ label: date.strftime("%a"), value: count }
-
end.reverse
-
}
-
-
1
chart_panel "User Signups (7 days)",
-
span: 4,
-
data: lambda {
-
(0..6).map do |i|
-
date = i.days.ago.to_date
-
count = User.where(created_at: date.beginning_of_day..date.end_of_day).count
-
{ label: date.strftime("%a"), value: count }
-
end.reverse
-
}
-
end
-
-
1
row do
-
1
stat_panel "Companies", -> { Company.count }, span: 2, variant: :mini, color: :slate
-
1
stat_panel "Job Roles", -> { JobRole.count }, span: 2, variant: :mini, color: :slate
-
1
stat_panel "Categories", -> { Category.count }, span: 2, variant: :mini, color: :slate
-
1
stat_panel "Skill Tags", -> { SkillTag.count }, span: 2, variant: :mini, color: :slate
-
1
stat_panel "Users", -> { User.count }, span: 2, variant: :mini, color: :green
-
1
stat_panel "Applications", -> { InterviewApplication.count }, span: 2, variant: :mini, color: :cyan
-
end
-
-
1
row do
-
1
stat_panel "Job Listings", -> { JobListing.count }, span: 2, variant: :mini, color: :slate
-
1
stat_panel "Resources", -> { Admin::Base::Resource.resources_for_portal(:ops).count }, span: 2, variant: :mini, color: :amber
-
end
-
-
1
row do
-
1
cards_panel "Content Management",
-
span: 12,
-
resources: [
-
{ resource_name: "companies", label: "Companies", description: "Company profiles and associations", icon: "building-2", count: -> { Company.count } },
-
{ resource_name: "job_roles", label: "Job Roles", description: "Job titles and definitions", icon: "briefcase", count: -> { JobRole.count } },
-
{ resource_name: "categories", label: "Categories", description: "Job role categories", icon: "layers", count: -> { Category.count } },
-
{ resource_name: "skill_tags", label: "Skill Tags", description: "Skills and competencies", icon: "tag", count: -> { SkillTag.count } },
-
{ resource_name: "job_listings", label: "Job Listings", description: "Jobs content management", icon: "file-text", count: -> { JobListing.count } },
-
{ resource_name: "blog_posts", label: "Blog Posts", description: "Blog content management", icon: "pencil-line", count: -> { BlogPost.count } }
-
]
-
end
-
-
1
row do
-
1
recent_panel "Recent Users",
-
span: 6,
-
scope: -> { User.order(created_at: :desc).limit(5) },
-
view_all_path: ->(view) { view.resources_path(portal: :ops, resource_name: "users") }
-
-
1
recent_panel "Recent Applications",
-
span: 6,
-
scope: -> { InterviewApplication.includes(:user, :company).order(created_at: :desc).limit(5) },
-
view_all_path: ->(view) { view.resources_path(portal: :ops, resource_name: "interview_applications") }
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
AdminSuite.portal :payments do
-
1
label "Payments Portal"
-
1
icon "credit-card"
-
1
color :emerald
-
1
order 50
-
1
description "Billing plans, subscriptions, and webhooks"
-
-
1
dashboard do
-
1
row do
-
1
stat_panel "Plans", -> { Billing::Plan.count }, span: 2, variant: :mini, color: :slate
-
1
stat_panel "Features", -> { Billing::Feature.count }, span: 2, variant: :mini, color: :slate
-
1
stat_panel "Entitlements", -> { Billing::PlanEntitlement.count }, span: 2, variant: :mini, color: :slate
-
1
stat_panel "Mappings", -> { Billing::ProviderMapping.count }, span: 2, variant: :mini, color: :slate
-
1
stat_panel "Subscriptions", -> { Billing::Subscription.count }, span: 2, variant: :mini, color: :cyan
-
1
stat_panel "Webhook Pending", -> { Billing::WebhookEvent.where(status: "pending").count }, span: 2, variant: :mini, color: :amber
-
end
-
-
1
row do
-
1
cards_panel "Billing Management",
-
span: 12,
-
resources: [
-
{ resource_name: "billing_plans", label: "Plans", description: "Subscription plans", icon: "package", count: -> { Billing::Plan.count } },
-
{ resource_name: "billing_features", label: "Features", description: "Entitlement features", icon: "badge-check", count: -> { Billing::Feature.count } },
-
{ resource_name: "billing_subscriptions", label: "Subscriptions", description: "Customer subscriptions", icon: "receipt", count: -> { Billing::Subscription.count } },
-
{ resource_name: "billing_webhook_events", label: "Webhook Events", description: "Incoming provider webhooks", icon: "webhook", count: -> { Billing::WebhookEvent.count } }
-
]
-
end
-
end
-
end
-
require "base64"
-
require "json"
-
-
module ApplicationCable
-
class Connection < ActionCable::Connection::Base
-
identified_by :current_user
-
-
def connect
-
set_current_user || reject_unauthorized_connection
-
end
-
-
private
-
def set_current_user
-
# Prefer the explicit signed session cookie (if present).
-
session_id = normalize_session_id(cookies.signed[:session_id])
-
-
# Fall back to Rails cookie_store session (contains session[:auth_session_id]).
-
if session_id.blank?
-
session_id = extract_session_id_from_rails_session_cookie
-
end
-
-
return nil if session_id.blank?
-
-
if (session = Session.find_by(id: session_id))
-
self.current_user = session.user
-
end
-
end
-
-
# When using :cookie_store, authenticated session id is stored inside the Rails session cookie
-
# under :auth_session_id. ActionCable connections don't have access to controller `session`,
-
# so we read/decrypt the session cookie directly.
-
#
-
# @return [String, nil]
-
def extract_session_id_from_rails_session_cookie
-
key = Rails.application.config.session_options[:key].to_s
-
return nil if key.blank?
-
-
# cookie_store uses encrypted cookies in modern Rails.
-
raw_session = cookies.encrypted[key]
-
raw_session = cookies.signed[key] if raw_session.nil?
-
return nil if raw_session.blank?
-
-
# Prefer Hash-shaped session payloads.
-
if raw_session.is_a?(Hash)
-
id = raw_session["auth_session_id"] || raw_session[:auth_session_id]
-
return id.to_s if id.present?
-
end
-
-
nil
-
rescue StandardError
-
nil
-
end
-
-
# Normalizes the signed cookie payload to a usable session id.
-
#
-
# Rails cookie jars may return:
-
# - a String/Integer session id
-
# - a metadata Hash (depending on cookie serializer/version)
-
#
-
# @param raw [Object]
-
# @return [String, nil]
-
def normalize_session_id(raw)
-
return nil if raw.blank?
-
-
if raw.is_a?(Hash)
-
payload = raw["_rails"] || raw[:_rails]
-
message = payload.is_a?(Hash) ? (payload["message"] || payload[:message]) : nil
-
raw = message if message.present?
-
end
-
-
value = raw.to_s
-
return value if value.match?(/\A\d+\z/)
-
-
# Some Rails cookie formats base64-encode the message.
-
if value.match?(/\A[A-Za-z0-9+\/]+=*\z/)
-
decoded = Base64.decode64(value).to_s
-
return decoded if decoded.match?(/\A\d+\z/)
-
-
# Some formats wrap the signed value as JSON metadata:
-
# {"_rails":{"message":"<base64>","exp":...,"pur":...}}
-
if decoded.strip.start_with?("{")
-
begin
-
data = JSON.parse(decoded)
-
payload = data["_rails"] || data.dig("_rails")
-
message = payload.is_a?(Hash) ? payload["message"] : nil
-
if message.is_a?(String)
-
inner = Base64.decode64(message).to_s
-
return inner if inner.match?(/\A\d+\z/)
-
end
-
rescue JSON::ParserError
-
# ignore
-
end
-
end
-
end
-
-
nil
-
rescue ArgumentError
-
nil
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# Routing constraint that requires developer authentication via TechWright SSO
-
#
-
# Used to protect routes like Mission Control Jobs that need admin access
-
# but don't go through the normal controller authentication flow.
-
#
-
# @example Usage in routes
-
# constraints DeveloperAuthenticatedConstraint.new do
-
# mount MissionControl::Jobs::Engine, at: "/jobs"
-
# end
-
#
-
1
class DeveloperAuthenticatedConstraint
-
# Checks if the request has a valid developer session
-
#
-
# @param request [ActionDispatch::Request] The incoming request
-
# @return [Boolean] True if developer is authenticated
-
1
def matches?(request)
-
developer_id = request.session[:developer_id]
-
then: 0
else: 0
return false if developer_id.blank?
-
-
Developer.enabled.exists?(id: developer_id)
-
end
-
end
-
# frozen_string_literal: true
-
-
module AiAssistant
-
# Controller for handling AI assistant queries
-
class QueriesController < ApplicationController
-
# POST /ai_assistant/ask
-
def ask
-
question = params[:question]
-
thread_uuid = params[:thread_uuid]
-
thread_id = params[:thread_id]
-
client_request_uuid = params[:client_request_uuid].presence
-
page_context = build_page_context_from_params
-
-
if question.blank?
-
render json: { error: "Question cannot be blank" }, status: :unprocessable_entity
-
return
-
end
-
-
trial_result = Billing::TrialUnlockService.new(user: Current.user, trigger: :first_ai_request).run
-
-
thread =
-
if thread_uuid.present?
-
Assistant::ChatThread.where(user: Current.user).find_by(uuid: thread_uuid)
-
elsif thread_id.present?
-
Assistant::ChatThread.where(user: Current.user).find_by(id: thread_id)
-
end
-
result = Assistant::Chat::Orchestrator.new(
-
user: Current.user,
-
thread: thread,
-
message: question,
-
page_context: page_context,
-
client_request_uuid: client_request_uuid
-
).call
-
-
render json: {
-
answer: result[:assistant_message].content,
-
thread_id: result[:thread].id,
-
thread_uuid: result[:thread].uuid,
-
trace_id: result[:trace_id],
-
tool_calls: result[:tool_calls],
-
trial_unlocked: trial_result[:unlocked] == true,
-
trial_expires_at: trial_result[:expires_at]
-
}
-
end
-
-
private
-
-
# Builds page context from request parameters
-
#
-
# @return [Hash] Page context for the assistant
-
def build_page_context_from_params
-
context = {}
-
-
context[:resume_id] = params[:resume_id].to_i if params[:resume_id].present?
-
context[:job_listing_id] = params[:job_listing_id].to_i if params[:job_listing_id].present?
-
if params[:interview_application_id].present?
-
raw = params[:interview_application_id].to_s.strip
-
if raw.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
-
context[:interview_application_uuid] = raw
-
elsif raw.match?(/\A\d+\z/)
-
context[:interview_application_id] = raw.to_i
-
else
-
context[:interview_application_uuid] = raw
-
end
-
end
-
context[:opportunity_id] = params[:opportunity_id].to_i if params[:opportunity_id].present?
-
context[:include_full_resume] = true if params[:include_full_resume] == "true" || params[:include_full_resume] == true
-
-
context.compact
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module AiAssistant
-
class ToolExecutionsController < ApplicationController
-
# POST /ai_assistant/tool_executions/:id/enqueue
-
def enqueue
-
tool_execution = scoped_tool_executions.find(params[:id])
-
-
if tool_execution.requires_confirmation && tool_execution.approved_by_id.nil?
-
render json: { error: "This tool requires approval before it can be executed." }, status: :unprocessable_entity
-
return
-
end
-
-
enqueued = false
-
tool_execution.with_lock do
-
if tool_execution.status == "proposed"
-
tool_execution.update!(status: "queued")
-
enqueued = true
-
end
-
end
-
-
AssistantToolExecutionJob.perform_later(tool_execution.id) if enqueued
-
-
render json: { status: tool_execution.status, tool_execution_id: tool_execution.id }
-
end
-
-
# POST /ai_assistant/tool_executions/:id/approve
-
def approve
-
tool_execution = scoped_tool_executions.find(params[:id])
-
-
unless tool_execution.requires_confirmation
-
render json: { error: "This tool does not require approval." }, status: :unprocessable_entity
-
return
-
end
-
-
enqueued = false
-
tool_execution.with_lock do
-
if %w[success running].include?(tool_execution.status)
-
# no-op
-
else
-
tool_execution.update!(
-
approved_by: (tool_execution.approved_by || Current.user),
-
approved_at: (tool_execution.approved_at || Time.current),
-
status: (tool_execution.status == "proposed" ? "queued" : tool_execution.status)
-
)
-
enqueued = (tool_execution.status == "queued")
-
end
-
end
-
-
AssistantToolExecutionJob.perform_later(tool_execution.id, approved_by_id: Current.user.id) if enqueued
-
-
render json: { status: tool_execution.status, tool_execution_id: tool_execution.id }
-
end
-
-
private
-
-
def scoped_tool_executions
-
Assistant::ToolExecution.joins(:thread).where(assistant_threads: { user_id: Current.user.id })
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# Base controller for API v1 endpoints
-
# Provides JSON-only responses and authentication
-
#
-
# Note: CSRF protection is enabled (default Rails behavior) since these APIs
-
# are consumed by same-origin JavaScript using session-based auth.
-
# The frontend includes the X-CSRF-Token header in all mutating requests.
-
class Api::V1::BaseController < ApplicationController
-
before_action :authenticate_api_user!
-
-
private
-
-
# Authenticates user for API requests
-
# Uses session-based auth (same as web app)
-
# @return [void]
-
def authenticate_api_user!
-
unless Current.user
-
render json: { error: "Unauthorized" }, status: :unauthorized
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# API controller for companies search and creation
-
class Api::V1::CompaniesController < Api::V1::BaseController
-
# GET /api/v1/companies
-
# Search companies
-
def index
-
@companies = Company.enabled.alphabetical
-
-
if params[:q].present?
-
@companies = @companies.where("name ILIKE ?", "%#{params[:q]}%")
-
end
-
-
@companies = @companies.limit(params[:limit] || 50)
-
-
render json: {
-
companies: @companies.map { |company| company_json(company) },
-
total: @companies.size
-
}
-
end
-
-
# POST /api/v1/companies
-
# Creates a new company (user-created)
-
def create
-
@company = Company.new(company_params)
-
-
if @company.save
-
render json: { success: true, company: company_json(@company) }, status: :created
-
else
-
render json: { success: false, errors: @company.errors.full_messages }, status: :unprocessable_entity
-
end
-
end
-
-
private
-
-
# Strong parameters for company creation
-
# @return [ActionController::Parameters]
-
def company_params
-
params.require(:company).permit(:name, :website, :about)
-
end
-
-
# Serializes company for JSON response
-
# @param company [Company]
-
# @return [Hash]
-
def company_json(company)
-
{
-
id: company.id,
-
name: company.name,
-
website: company.website,
-
logo_url: company.logo_url
-
}
-
end
-
end
-
# frozen_string_literal: true
-
-
# API controller for departments (job role categories)
-
class Api::V1::DepartmentsController < Api::V1::BaseController
-
# GET /api/v1/departments
-
# List all departments with their job role counts
-
def index
-
@departments = Category.departments
-
-
render json: {
-
departments: @departments.map { |dept| department_json(dept) }
-
}
-
end
-
-
private
-
-
# Serializes department for JSON response
-
# @param dept [Category]
-
# @return [Hash]
-
def department_json(dept)
-
{
-
id: dept.id,
-
name: dept.name,
-
job_role_count: dept.job_roles.enabled.count
-
}
-
end
-
end
-
# frozen_string_literal: true
-
-
# API controller for domains search and creation
-
class Api::V1::DomainsController < Api::V1::BaseController
-
# GET /api/v1/domains
-
# Search domains
-
def index
-
@domains = Domain.enabled.alphabetical
-
-
if params[:q].present?
-
@domains = @domains.search(params[:q])
-
end
-
-
@domains = @domains.limit(params[:limit] || 50)
-
-
render json: {
-
domains: @domains.map { |domain| domain_json(domain) },
-
total: @domains.size
-
}
-
end
-
-
# POST /api/v1/domains
-
# Creates a new domain (user-created)
-
def create
-
@domain = Domain.new(domain_params)
-
-
if @domain.save
-
render json: { success: true, domain: domain_json(@domain) }, status: :created
-
else
-
render json: { success: false, errors: @domain.errors.full_messages }, status: :unprocessable_entity
-
end
-
end
-
-
private
-
-
# Strong parameters for domain creation
-
# @return [ActionController::Parameters]
-
def domain_params
-
params.require(:domain).permit(:name, :description)
-
end
-
-
# Serializes domain for JSON response
-
# @param domain [Domain]
-
# @return [Hash]
-
def domain_json(domain)
-
{
-
id: domain.id,
-
name: domain.name,
-
slug: domain.slug,
-
description: domain.description
-
}
-
end
-
end
-
# frozen_string_literal: true
-
-
# API controller for job roles search and creation
-
class Api::V1::JobRolesController < Api::V1::BaseController
-
# GET /api/v1/job_roles
-
# Search job roles with optional department filter
-
def index
-
@job_roles = JobRole.enabled.alphabetical
-
-
if params[:q].present?
-
@job_roles = @job_roles.search(params[:q])
-
end
-
-
if params[:department_id].present?
-
@job_roles = @job_roles.by_department(params[:department_id])
-
end
-
-
@job_roles = @job_roles.includes(:category).limit(params[:limit] || 50)
-
-
render json: {
-
job_roles: @job_roles.map { |role| job_role_json(role) },
-
total: @job_roles.size
-
}
-
end
-
-
# POST /api/v1/job_roles
-
# Creates a new job role (user-created)
-
def create
-
@job_role = JobRole.new(job_role_params)
-
-
# Assign to department if provided
-
if params[:department_id].present?
-
@job_role.category = Category.find_by(id: params[:department_id], kind: :job_role)
-
end
-
-
if @job_role.save
-
render json: { success: true, job_role: job_role_json(@job_role) }, status: :created
-
else
-
render json: { success: false, errors: @job_role.errors.full_messages }, status: :unprocessable_entity
-
end
-
end
-
-
private
-
-
# Strong parameters for job role creation
-
# @return [ActionController::Parameters]
-
def job_role_params
-
params.require(:job_role).permit(:title, :description)
-
end
-
-
# Serializes job role for JSON response
-
# @param role [JobRole]
-
# @return [Hash]
-
def job_role_json(role)
-
{
-
id: role.id,
-
title: role.title,
-
description: role.description,
-
department_id: role.category_id,
-
department_name: role.department_name
-
}
-
end
-
end
-
1
class ApplicationController < ActionController::Base
-
1
include Authentication
-
1
include TurnstileHelper
-
1
include Paginatable
-
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
-
1
allow_browser versions: :modern
-
-
# Changes to the importmap will invalidate the etag for HTML responses
-
1
stale_when_importmap_changes
-
-
# Set layout based on authentication status
-
1
layout :determine_layout
-
-
1
private
-
-
1
def determine_layout
-
then: 0
if authenticated?
-
"authenticated"
-
else: 0
else
-
"application"
-
end
-
end
-
-
1
def authenticated?
-
Current.user.present?
-
end
-
end
-
# frozen_string_literal: true
-
-
class ArchivedJobsController < ApplicationController
-
# GET /archived_jobs
-
def index
-
@archived_opportunities = Current.user.opportunities.archived
-
.order(archived_at: :desc)
-
-
@archived_saved_jobs = Current.user.saved_jobs.archived
-
.includes(:opportunity)
-
.order(archived_at: :desc)
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
# POST /assistant/threads/:thread_uuid/messages
-
#
-
# Creates a user message immediately and enqueues async LLM processing.
-
# Returns turbo_stream with user message + thinking indicator.
-
class MessagesController < ApplicationController
-
def create
-
@thread = ChatThread.where(user: Current.user).find_by!(uuid: params[:thread_uuid])
-
question = params[:content].to_s.strip
-
client_request_uuid = params[:client_request_uuid].presence
-
-
if question.blank?
-
head :unprocessable_entity
-
return
-
end
-
-
# Check for duplicate request (idempotency)
-
if client_request_uuid.present?
-
existing_turn = Assistant::Turn.where(thread: @thread, client_request_uuid: client_request_uuid).first
-
if existing_turn
-
@user_message = existing_turn.user_message
-
@assistant_message = existing_turn.assistant_message
-
@show_thinking = false
-
respond_to do |format|
-
format.turbo_stream
-
format.html { redirect_to assistant_thread_path(@thread) }
-
end
-
return
-
end
-
end
-
-
# Build page context from params
-
# This allows the frontend to indicate which page the user is on,
-
# enabling context-aware features like including full resume text
-
page_context = build_page_context_from_params
-
-
# Create user message immediately
-
trace_id = SecureRandom.uuid
-
@user_message = @thread.messages.create!(
-
role: "user",
-
content: question,
-
metadata: { trace_id: trace_id, page_context: page_context }
-
)
-
-
# Update thread activity
-
@thread.update!(last_activity_at: Time.current)
-
-
# Auto-generate title from first message if thread has no title
-
if @thread.title.blank? && @thread.messages.where(role: "user").count == 1
-
@thread.update!(title: question.truncate(50))
-
end
-
-
# Enqueue async LLM processing
-
@trace_id = trace_id
-
AssistantChatJob.perform_later(
-
thread_id: @thread.id,
-
user_id: Current.user.id,
-
user_message_id: @user_message.id,
-
trace_id: trace_id,
-
client_request_uuid: client_request_uuid
-
)
-
-
@show_thinking = true
-
maybe_unlock_insight_trial_after_first_ai_request
-
-
respond_to do |format|
-
format.turbo_stream
-
format.html { redirect_to assistant_thread_path(@thread) }
-
end
-
end
-
-
private
-
-
# Builds page context from request parameters
-
#
-
# Supported context fields:
-
# - resume_id: User is viewing a resume (triggers full resume text inclusion)
-
# - job_listing_id: User is viewing a job listing
-
# - interview_application_id: User is viewing an application
-
# - opportunity_id: User is viewing an opportunity
-
# - include_full_resume: Explicit flag to include full resume text
-
#
-
# @return [Hash] Page context for the assistant
-
def build_page_context_from_params
-
context = {}
-
-
# Extract context IDs from params
-
context[:resume_id] = params[:resume_id].to_i if params[:resume_id].present?
-
context[:job_listing_id] = params[:job_listing_id].to_i if params[:job_listing_id].present?
-
if params[:interview_application_id].present?
-
raw = params[:interview_application_id].to_s.strip
-
if raw.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
-
context[:interview_application_uuid] = raw
-
elsif raw.match?(/\A\d+\z/)
-
context[:interview_application_id] = raw.to_i
-
else
-
# FriendlyId slug is likely present; treat as UUID-ish identifier
-
context[:interview_application_uuid] = raw
-
end
-
end
-
context[:opportunity_id] = params[:opportunity_id].to_i if params[:opportunity_id].present?
-
-
# Explicit flags
-
context[:include_full_resume] = true if params[:include_full_resume] == "true" || params[:include_full_resume] == true
-
-
context.compact
-
end
-
-
# Unlocks the insight-triggered trial on the user's first AI assistant request.
-
#
-
# @return [void]
-
def maybe_unlock_insight_trial_after_first_ai_request
-
result = Billing::TrialUnlockService.new(user: Current.user, trigger: :first_ai_request).run
-
return unless result[:unlocked]
-
-
flash.now[:notice] = "You've unlocked Pro insights for 72 hours."
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
class ThreadsController < ApplicationController
-
def index
-
@threads = ChatThread.where(user: Current.user).order(last_activity_at: :desc, created_at: :desc)
-
end
-
-
def show
-
@thread = ChatThread.where(user: Current.user).find_by!(uuid: params[:uuid])
-
# Tool messages are persisted for provider replay, but should not render as chat bubbles.
-
@messages = @thread.messages.where(role: %w[user assistant]).order(:created_at)
-
@tool_executions = @thread.tool_executions.order(created_at: :desc)
-
@tool_action_items = @tool_executions.select { |te| te.status.in?(%w[proposed queued running]) }
-
end
-
-
def create
-
thread = ChatThread.create!(user: Current.user, title: nil, status: "open", last_activity_at: Time.current)
-
redirect_to assistant_thread_path(thread)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
class ToolExecutionsController < ApplicationController
-
def approve
-
tool_execution = scoped.find_by!(uuid: params[:uuid])
-
-
if tool_execution.requires_confirmation? == false
-
redirect_back fallback_location: assistant_thread_path(tool_execution.thread), alert: "This tool does not require approval."
-
return
-
end
-
-
tool_execution = consolidate_batch_tool_executions!(tool_execution)
-
invalid_errors = validate_tool_execution_args(tool_execution)
-
if invalid_errors.any?
-
mark_tool_execution_invalid!(tool_execution, invalid_errors)
-
switch_originating_message_to_placeholder!(tool_execution)
-
broadcast_tool_proposals(tool_execution.thread)
-
enqueue_followup_if_ready(tool_execution)
-
return redirect_back fallback_location: assistant_thread_path(tool_execution.thread), alert: "Couldn't run this action: #{invalid_errors.join(', ')}"
-
end
-
-
enqueued = false
-
tool_execution.with_lock do
-
if %w[success running].include?(tool_execution.status)
-
# no-op
-
else
-
tool_execution.update!(
-
approved_by: (tool_execution.approved_by || Current.user),
-
approved_at: (tool_execution.approved_at || Time.current),
-
status: (tool_execution.status == "proposed" ? "queued" : tool_execution.status)
-
)
-
enqueued = (tool_execution.status == "queued")
-
end
-
end
-
-
AssistantToolExecutionJob.perform_later(tool_execution.id, approved_by_id: Current.user.id) if enqueued
-
-
if enqueued
-
switch_originating_message_to_placeholder!(tool_execution)
-
broadcast_tool_proposals(tool_execution.thread)
-
end
-
-
respond_to do |format|
-
format.turbo_stream do
-
streams = []
-
streams << turbo_stream.replace(ActionView::RecordIdentifier.dom_id(tool_execution.thread, :tool_executions), partial: "assistant/threads/tool_proposals", locals: {
-
thread: tool_execution.thread,
-
tool_executions: tool_execution.thread.tool_executions.where(status: %w[proposed queued running]).order(created_at: :asc)
-
})
-
streams << turbo_stream.replace(ActionView::RecordIdentifier.dom_id(tool_execution.assistant_message), partial: "assistant/threads/message", locals: { message: tool_execution.assistant_message }) if enqueued
-
render turbo_stream: streams
-
end
-
format.html { redirect_back fallback_location: assistant_thread_path(tool_execution.thread), notice: (enqueued ? "Approved and enqueued." : "Already running or finished.") }
-
end
-
end
-
-
def enqueue
-
tool_execution = scoped.find_by!(uuid: params[:uuid])
-
-
if tool_execution.requires_confirmation? && tool_execution.approved_by_id.nil?
-
redirect_back fallback_location: assistant_thread_path(tool_execution.thread), alert: "This tool requires approval before it can be executed."
-
return
-
end
-
-
tool_execution = consolidate_batch_tool_executions!(tool_execution)
-
invalid_errors = validate_tool_execution_args(tool_execution)
-
if invalid_errors.any?
-
mark_tool_execution_invalid!(tool_execution, invalid_errors)
-
switch_originating_message_to_placeholder!(tool_execution)
-
broadcast_tool_proposals(tool_execution.thread)
-
enqueue_followup_if_ready(tool_execution)
-
return redirect_back fallback_location: assistant_thread_path(tool_execution.thread), alert: "Couldn't run this action: #{invalid_errors.join(', ')}"
-
end
-
-
enqueued = false
-
tool_execution.with_lock do
-
if tool_execution.status == "proposed"
-
tool_execution.update!(status: "queued")
-
enqueued = true
-
end
-
end
-
-
AssistantToolExecutionJob.perform_later(tool_execution.id) if enqueued
-
-
if enqueued
-
switch_originating_message_to_placeholder!(tool_execution)
-
broadcast_tool_proposals(tool_execution.thread)
-
end
-
-
respond_to do |format|
-
format.turbo_stream do
-
streams = []
-
streams << turbo_stream.replace(ActionView::RecordIdentifier.dom_id(tool_execution.thread, :tool_executions), partial: "assistant/threads/tool_proposals", locals: {
-
thread: tool_execution.thread,
-
tool_executions: tool_execution.thread.tool_executions.where(status: %w[proposed queued running]).order(created_at: :asc)
-
})
-
streams << turbo_stream.replace(ActionView::RecordIdentifier.dom_id(tool_execution.assistant_message), partial: "assistant/threads/message", locals: { message: tool_execution.assistant_message }) if enqueued
-
render turbo_stream: streams
-
end
-
format.html { redirect_back fallback_location: assistant_thread_path(tool_execution.thread), notice: (enqueued ? "Enqueued." : "Already queued or processed.") }
-
end
-
end
-
-
private
-
-
def scoped
-
::Assistant::ToolExecution.joins(:thread).where(assistant_threads: { user_id: Current.user.id })
-
end
-
-
def batchable_tool_key?(tool_key)
-
tool_key.to_s.in?(%w[add_target_company add_target_job_role remove_target_company remove_target_job_role])
-
end
-
-
def consolidate_batch_tool_executions!(tool_execution)
-
return tool_execution unless batchable_tool_key?(tool_execution.tool_key)
-
-
siblings = scoped.where(
-
thread_id: tool_execution.thread_id,
-
assistant_message_id: tool_execution.assistant_message_id,
-
tool_key: tool_execution.tool_key,
-
status: "proposed",
-
requires_confirmation: true,
-
approved_by_id: nil
-
).order(created_at: :asc).to_a
-
-
return tool_execution if siblings.size <= 1
-
-
primary = siblings.first
-
merged_args =
-
case tool_execution.tool_key.to_s
-
when "add_target_company"
-
merge_company_args(siblings.map(&:args))
-
when "add_target_job_role"
-
merge_job_role_args(siblings.map(&:args))
-
when "remove_target_company"
-
merge_company_args(siblings.map(&:args))
-
when "remove_target_job_role"
-
merge_job_role_args(siblings.map(&:args))
-
else
-
primary.args
-
end
-
-
# Approving one means we approve the entire grouped action.
-
now = Time.current
-
primary.update!(args: merged_args)
-
-
(siblings - [ primary ]).each do |te|
-
te.update!(
-
approved_by: Current.user,
-
approved_at: now,
-
status: "success",
-
finished_at: now,
-
result: {
-
deduped: true,
-
merged_into_tool_execution_id: primary.id
-
},
-
error: nil,
-
metadata: (te.metadata || {}).merge("deduped" => true, "merged_into_tool_execution_id" => primary.id)
-
)
-
end
-
-
primary
-
end
-
-
def merge_company_args(args_list)
-
items = []
-
Array(args_list).each do |args|
-
args = args.is_a?(Hash) ? args : {}
-
companies = args["companies"] || args[:companies]
-
if companies.is_a?(Array)
-
companies.each { |it| items << (it.is_a?(Hash) ? it : {}) }
-
else
-
items << args.slice("company_id", "company_name", "priority").merge(args.slice(:company_id, :company_name, :priority))
-
end
-
end
-
-
uniq = {}
-
items.each do |it|
-
cid = (it["company_id"] || it[:company_id]).to_s.presence
-
name = (it["company_name"] || it[:company_name]).to_s.strip
-
key = cid.presence || "name:#{name.downcase}"
-
next if key.blank? || key == "name:"
-
pr = it["priority"] || it[:priority]
-
uniq[key] ||= {}
-
uniq[key]["company_id"] = cid.to_i if cid.present?
-
uniq[key]["company_name"] = name if name.present?
-
uniq[key]["priority"] = pr if pr.present?
-
end
-
-
{ "companies" => uniq.values }
-
end
-
-
def merge_job_role_args(args_list)
-
items = []
-
Array(args_list).each do |args|
-
args = args.is_a?(Hash) ? args : {}
-
roles = args["job_roles"] || args[:job_roles]
-
if roles.is_a?(Array)
-
roles.each { |it| items << (it.is_a?(Hash) ? it : {}) }
-
else
-
items << args.slice("job_role_id", "job_role_title", "priority").merge(args.slice(:job_role_id, :job_role_title, :priority))
-
end
-
end
-
-
uniq = {}
-
items.each do |it|
-
rid = (it["job_role_id"] || it[:job_role_id]).to_s.presence
-
title = (it["job_role_title"] || it[:job_role_title]).to_s.strip
-
key = rid.presence || "title:#{title.downcase}"
-
next if key.blank? || key == "title:"
-
pr = it["priority"] || it[:priority]
-
uniq[key] ||= {}
-
uniq[key]["job_role_id"] = rid.to_i if rid.present?
-
uniq[key]["job_role_title"] = title if title.present?
-
uniq[key]["priority"] = pr if pr.present?
-
end
-
-
{ "job_roles" => uniq.values }
-
end
-
-
def switch_originating_message_to_placeholder!(tool_execution)
-
msg = tool_execution.assistant_message
-
return if msg.nil?
-
-
msg.update!(
-
content: "Working on it — I’m fetching the latest info now.",
-
metadata: msg.metadata.merge("pending_tool_followup" => true)
-
)
-
end
-
-
def validate_tool_execution_args(tool_execution)
-
tool = ::Assistant::Tool.find_by(tool_key: tool_execution.tool_key)
-
return [] if tool.nil?
-
-
::Assistant::Tools::ArgSchemaValidator.new(tool.arg_schema).validate(tool_execution.args)
-
end
-
-
def mark_tool_execution_invalid!(tool_execution, errors)
-
now = Time.current
-
tool_execution.update!(
-
status: "error",
-
finished_at: now,
-
error: errors.join(", "),
-
metadata: (tool_execution.metadata || {}).merge("error_type" => "schema_invalid")
-
)
-
-
# Persist tool result so provider histories can safely include tool_result for tool_use_id
-
::Assistant::Chat::ToolResultMessagePersister.new(tool_execution: tool_execution).call
-
rescue StandardError
-
# best-effort
-
end
-
-
def enqueue_followup_if_ready(tool_execution)
-
thread = tool_execution.thread
-
assistant_message_id = tool_execution.assistant_message_id
-
return if assistant_message_id.blank?
-
-
pending = thread.tool_executions.where(assistant_message_id: assistant_message_id, status: %w[proposed queued running]).exists?
-
return if pending
-
-
AssistantToolFollowupJob.perform_later(assistant_message_id)
-
rescue StandardError
-
# best-effort
-
end
-
-
def broadcast_tool_proposals(thread)
-
tool_executions = thread.tool_executions.where(status: %w[proposed queued running]).order(created_at: :asc)
-
Turbo::StreamsChannel.broadcast_replace_to(
-
"assistant_thread_#{thread.id}",
-
target: ActionView::RecordIdentifier.dom_id(thread, :tool_executions),
-
partial: "assistant/threads/tool_proposals",
-
locals: { thread: thread, tool_executions: tool_executions }
-
)
-
rescue StandardError
-
# best-effort only
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
# GET /assistant/widget
-
# GET /assistant/widget/threads
-
# POST /assistant/widget/new_thread
-
#
-
# Handles the floating assistant widget that appears across the app.
-
class WidgetsController < ApplicationController
-
# GET /assistant/widget
-
# Shows a thread in the widget. If thread_uuid is provided, shows that thread.
-
# Otherwise shows the most recent thread (or creates one).
-
def show
-
@thread = if params[:thread_uuid].present?
-
ChatThread.where(user: Current.user).find_by!(uuid: params[:thread_uuid])
-
else
-
find_or_create_thread
-
end
-
load_thread_data
-
render layout: false
-
end
-
-
# GET /assistant/widget/threads
-
# Returns a list of recent threads for the thread switcher dropdown
-
def threads
-
@threads = ChatThread.where(user: Current.user)
-
.order(last_activity_at: :desc, created_at: :desc)
-
.limit(10)
-
render layout: false
-
end
-
-
# POST /assistant/widget/new_thread
-
# Creates a new thread and switches to it in the widget
-
def new_thread
-
@thread = ChatThread.create!(
-
user: Current.user,
-
title: nil,
-
status: "open",
-
last_activity_at: Time.current
-
)
-
load_thread_data
-
render :show, layout: false
-
end
-
-
private
-
-
def find_or_create_thread
-
ChatThread.where(user: Current.user)
-
.order(last_activity_at: :desc, created_at: :desc)
-
.first ||
-
ChatThread.create!(
-
user: Current.user,
-
title: nil,
-
status: "open",
-
last_activity_at: Time.current
-
)
-
end
-
-
def load_thread_data
-
# Tool messages are persisted for provider replay, but should not render in the widget.
-
@messages = @thread.messages.where(role: %w[user assistant]).order(:created_at)
-
@tool_executions = @thread.tool_executions.order(created_at: :desc)
-
@tool_proposals = @tool_executions.select { |te| te.status == "proposed" }
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Billing
-
# Starts a hosted checkout for a given plan.
-
class CheckoutsController < ApplicationController
-
# POST /billing/checkout/:plan_key
-
def create
-
plan = Billing::Plan.find_by!(key: params[:plan_key])
-
-
# Prepare plan switch (cancel conflicting plans before checkout)
-
switcher = Billing::PlanSwitcher.new(Current.user)
-
switch_result = switcher.prepare_switch(plan)
-
-
if switch_result[:cancelled_subscription]
-
Rails.logger.info(
-
"[billing] checkout cancelled existing subscription for plan switch " \
-
"user_id=#{Current.user.id} new_plan=#{plan.key}"
-
)
-
end
-
-
if switch_result[:deactivated_grant]
-
Rails.logger.info(
-
"[billing] checkout deactivated existing purchase grant for plan switch " \
-
"user_id=#{Current.user.id} new_plan=#{plan.key}"
-
)
-
end
-
-
url = Billing::Providers::LemonSqueezy.new.create_checkout(user: Current.user, plan: plan)
-
-
# Important: LemonSqueezy checkout is on a different origin. If this action is
-
# submitted via Turbo (fetch/XHR), the redirect will be blocked by the browser's
-
# CORS policy. We also disable Turbo on the checkout forms, but return a 303 to
-
# encourage a full navigation.
-
redirect_to url, allow_other_host: true, status: :see_other
-
rescue ActiveRecord::RecordNotFound
-
redirect_to settings_path(tab: "billing"), alert: "Plan not found."
-
rescue => e
-
ExceptionNotifier.notify(e, context: "payment", severity: "error", user: { id: Current.user&.id, email: Current.user&.email_address }, plan_key: params[:plan_key])
-
redirect_to settings_path(tab: "billing"), alert: "Could not start checkout. Please try again."
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Billing
-
# Controller for redirecting users to the LemonSqueezy customer portal.
-
class PortalController < ApplicationController
-
# GET /billing/portal
-
#
-
# Redirects to the LemonSqueezy customer portal for managing payment methods and invoices.
-
def show
-
customer = Current.user.billing_customers.find_by(provider: "lemonsqueezy")
-
-
unless customer
-
redirect_to settings_path(tab: "billing", subtab: "billing"),
-
alert: "No billing account found. Please subscribe to a plan first."
-
return
-
end
-
-
provider = Billing::Providers::LemonSqueezy.new
-
url = provider.customer_portal_url(customer: customer)
-
-
redirect_to url, allow_other_host: true
-
rescue StandardError => e
-
Rails.logger.error("[billing] Failed to get customer portal URL: #{e.message}")
-
redirect_to settings_path(tab: "billing", subtab: "billing"),
-
alert: "Failed to access billing portal. Please try again or contact support."
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Billing
-
# Optional return/confirmation page after LemonSqueezy checkout.
-
#
-
# Note: subscription activation should still rely on webhooks; this page is
-
# strictly for UX and support context (order_id/order_identifier).
-
class ReturnsController < ApplicationController
-
allow_unauthenticated_access only: [ :show ]
-
-
# GET /billing/return
-
def show
-
@order_id = params[:order_id]
-
@order_identifier = params[:order_identifier]
-
@email = params[:email]
-
@name = params[:name]
-
@total = params[:total]
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Billing
-
# Controller for managing subscription actions (cancel/resume).
-
class SubscriptionsController < ApplicationController
-
before_action :set_subscription
-
-
# POST /billing/subscription/cancel
-
#
-
# Sets the subscription to cancel at period end.
-
def cancel
-
unless @subscription
-
redirect_to settings_path(tab: "billing", subtab: "subscription"),
-
alert: "No active subscription found."
-
return
-
end
-
-
if @subscription.cancel_at_period_end?
-
redirect_to settings_path(tab: "billing", subtab: "subscription"),
-
notice: "Subscription is already set to cancel."
-
return
-
end
-
-
provider = Billing::Providers::LemonSqueezy.new
-
provider.cancel_subscription(subscription: @subscription)
-
-
redirect_to settings_path(tab: "billing", subtab: "subscription"),
-
notice: "Your subscription will cancel at the end of the current billing period."
-
rescue StandardError => e
-
Rails.logger.error("[billing] Failed to cancel subscription: #{e.message}")
-
redirect_to settings_path(tab: "billing", subtab: "subscription"),
-
alert: "Failed to cancel subscription. Please try again or contact support."
-
end
-
-
# POST /billing/subscription/resume
-
#
-
# Removes the cancellation from a subscription.
-
def resume
-
unless @subscription
-
redirect_to settings_path(tab: "billing", subtab: "subscription"),
-
alert: "No active subscription found."
-
return
-
end
-
-
unless @subscription.cancel_at_period_end?
-
redirect_to settings_path(tab: "billing", subtab: "subscription"),
-
notice: "Subscription is not set to cancel."
-
return
-
end
-
-
provider = Billing::Providers::LemonSqueezy.new
-
provider.resume_subscription(subscription: @subscription)
-
-
redirect_to settings_path(tab: "billing", subtab: "subscription"),
-
notice: "Your subscription has been resumed and will continue renewing."
-
rescue StandardError => e
-
Rails.logger.error("[billing] Failed to resume subscription: #{e.message}")
-
redirect_to settings_path(tab: "billing", subtab: "subscription"),
-
alert: "Failed to resume subscription. Please try again or contact support."
-
end
-
-
private
-
-
def set_subscription
-
entitlements = Billing::Entitlements.for(Current.user)
-
@subscription = entitlements.active_subscription
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# Controller for category autocomplete + JSON create.
-
# Used by the shared autocomplete component.
-
class CategoriesController < ApplicationController
-
# GET /categories
-
def index
-
@categories = Category.enabled.alphabetical
-
@categories = @categories.for_kind(params[:kind]) if params[:kind].present?
-
-
if params[:q].present?
-
@categories = @categories.where("name ILIKE ?", "%#{params[:q]}%")
-
end
-
-
@categories = @categories.limit(50)
-
-
respond_to do |format|
-
format.html
-
format.json { render json: @categories }
-
end
-
end
-
-
# GET /categories/autocomplete
-
def autocomplete
-
query = params[:q].to_s.strip
-
kind = params[:kind].presence
-
-
categories = Category.enabled.alphabetical
-
categories = categories.for_kind(kind) if kind
-
-
categories = if query.present?
-
categories.where("name ILIKE ?", "%#{query}%").limit(10)
-
else
-
categories.limit(10)
-
end
-
-
render json: categories.map { |c| { id: c.id, name: c.name, category: c.kind } }
-
end
-
-
# POST /categories
-
def create
-
return head :not_acceptable unless request.format.json?
-
-
name = (params[:name] || params.dig(:category, :name))&.strip
-
kind = (params[:kind] || params.dig(:category, :kind))&.to_s
-
-
return render json: { errors: [ "Name is required" ] }, status: :unprocessable_entity if name.blank?
-
return render json: { errors: [ "Kind is required" ] }, status: :unprocessable_entity if kind.blank?
-
return render json: { errors: [ "Kind is invalid" ] }, status: :unprocessable_entity unless Category.kinds.key?(kind)
-
-
category = Category.where("LOWER(name) = ? AND kind = ?", name.downcase, Category.kinds[kind]).first
-
-
if category.nil?
-
category = Category.new(name: name, kind: kind)
-
if category.save
-
render json: { id: category.id, name: category.name }, status: :created
-
else
-
render json: { errors: category.errors.full_messages }, status: :unprocessable_entity
-
end
-
else
-
category.update!(disabled_at: nil) if category.disabled?
-
render json: { id: category.id, name: category.name }, status: :ok
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# Controller for managing companies
-
class CompaniesController < ApplicationController
-
# GET /companies
-
def index
-
@companies = Company.enabled.alphabetical
-
-
if params[:q].present?
-
@companies = @companies.where("name ILIKE ?", "%#{params[:q]}%")
-
end
-
-
@companies = @companies.limit(50)
-
-
respond_to do |format|
-
format.html
-
format.json { render json: @companies }
-
end
-
end
-
-
# GET /companies/autocomplete
-
def autocomplete
-
query = params[:q].to_s.strip
-
-
@companies = if query.present?
-
Company.enabled.where("name ILIKE ?", "%#{query}%")
-
.alphabetical
-
.limit(10)
-
else
-
Company.enabled.alphabetical.limit(10)
-
end
-
-
render json: @companies.map { |c| { id: c.id, name: c.name, website: c.website } }
-
end
-
-
# POST /companies
-
def create
-
# Handle both form params and JSON params (for auto-create)
-
if request.format.json?
-
# Auto-create from autocomplete - only name is required
-
name = (params[:name] || params.dig(:company, :name))&.strip
-
return render json: { errors: [ "Name is required" ] }, status: :unprocessable_entity if name.blank?
-
-
# Find by case-insensitive name
-
@company = Company.where("LOWER(name) = ?", name.downcase).first
-
-
if @company.nil?
-
# Create new company
-
@company = Company.new(name: name)
-
if @company.save
-
render json: { id: @company.id, name: @company.name }, status: :created
-
else
-
render json: { errors: @company.errors.full_messages }, status: :unprocessable_entity
-
end
-
else
-
# If it exists but was disabled, re-enable it
-
@company.update!(disabled_at: nil) if @company.disabled?
-
# Company already exists, return it
-
render json: { id: @company.id, name: @company.name }, status: :ok
-
end
-
else
-
# Regular form submission
-
@company = Company.new(company_params)
-
-
if @company.save
-
respond_to do |format|
-
format.html { redirect_to companies_path, notice: "Company created successfully!" }
-
format.turbo_stream { flash.now[:notice] = "Company created successfully!" }
-
end
-
else
-
respond_to do |format|
-
format.html { render :new, status: :unprocessable_entity }
-
end
-
end
-
end
-
end
-
-
private
-
-
def company_params
-
params.expect(company: [ :name, :website, :about, :logo_url ])
-
end
-
end
-
# frozen_string_literal: true
-
-
# Controller for managing company feedback for interview applications
-
class CompanyFeedbacksController < ApplicationController
-
before_action :set_application
-
before_action :set_feedback, only: [:show, :edit, :update, :destroy]
-
-
# GET /interview_applications/:interview_application_id/company_feedback
-
def show
-
end
-
-
# GET /interview_applications/:interview_application_id/company_feedback/new
-
def new
-
@feedback = @application.build_company_feedback
-
end
-
-
# GET /interview_applications/:interview_application_id/company_feedback/edit
-
def edit
-
end
-
-
# POST /interview_applications/:interview_application_id/company_feedback
-
def create
-
@feedback = @application.build_company_feedback(feedback_params)
-
-
if @feedback.save
-
respond_to do |format|
-
format.html { redirect_to interview_application_path(@application), notice: "Company feedback added successfully!" }
-
format.turbo_stream { flash.now[:notice] = "Company feedback added successfully!" }
-
end
-
else
-
render :new, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH/PUT /interview_applications/:interview_application_id/company_feedback
-
def update
-
if @feedback.update(feedback_params)
-
respond_to do |format|
-
format.html { redirect_to interview_application_path(@application), notice: "Company feedback updated successfully!" }
-
format.turbo_stream { flash.now[:notice] = "Company feedback updated successfully!" }
-
end
-
else
-
render :edit, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /interview_applications/:interview_application_id/company_feedback
-
def destroy
-
@feedback.destroy
-
-
respond_to do |format|
-
format.html { redirect_to interview_application_path(@application), notice: "Company feedback deleted successfully!", status: :see_other }
-
format.turbo_stream { flash.now[:notice] = "Company feedback deleted successfully!" }
-
end
-
end
-
-
private
-
-
def set_application
-
@application = Current.user.interview_applications.find(params[:interview_application_id])
-
rescue ActiveRecord::RecordNotFound
-
redirect_to interview_applications_path, alert: "Application not found"
-
end
-
-
def set_feedback
-
@feedback = @application.company_feedback
-
redirect_to interview_application_path(@application), alert: "Feedback not found" if @feedback.nil?
-
end
-
-
def feedback_params
-
params.expect(company_feedback: [
-
:feedback_text,
-
:received_at,
-
:rejection_reason,
-
:next_steps,
-
:self_reflection
-
])
-
end
-
end
-
-
1
module Authentication
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
1
before_action :resume_session # Always resume session to make authenticated? work in views
-
1
before_action :require_authentication
-
1
helper_method :authenticated?
-
end
-
-
1
class_methods do
-
1
def allow_unauthenticated_access(**options)
-
skip_before_action :require_authentication, **options
-
end
-
end
-
-
1
private
-
1
def authenticated?
-
resume_session
-
end
-
-
1
def require_authentication
-
set_no_cache_headers
-
resume_session || request_authentication
-
end
-
-
1
def set_no_cache_headers
-
response.set_header("Cache-Control", "no-store, private")
-
end
-
-
1
def resume_session
-
7
Current.session ||= find_session_by_cookie
-
end
-
-
1
def find_session_by_cookie
-
# Prefer Rails session storage (works even when ActionDispatch::Cookies isn't fully available)
-
7
then: 7
else: 0
if respond_to?(:session) && session.respond_to?(:[])
-
7
session_id = session[:auth_session_id]
-
7
then: 0
else: 7
return Session.find_by(id: session_id) if session_id.present?
-
end
-
-
# Fallback to signed cookie storage when available
-
7
then: 0
else: 0
else: 0
then: 7
return nil unless respond_to?(:cookies) && cookies&.respond_to?(:signed)
-
-
raw = cookies.signed[:session_id]
-
session_id = normalize_session_id(raw)
-
then: 0
else: 0
return nil if session_id.blank?
-
-
Session.find_by(id: session_id)
-
end
-
-
# Normalizes the signed cookie payload to a usable session id.
-
#
-
# Rails cookie jars may return:
-
# - a String/Integer session id
-
# - a metadata Hash (depending on cookie serializer/version)
-
#
-
# @param raw [Object]
-
# @return [String, nil]
-
1
def normalize_session_id(raw)
-
then: 0
else: 0
return nil if raw.blank?
-
-
then: 0
else: 0
if raw.is_a?(Hash)
-
payload = raw["_rails"] || raw[:_rails]
-
then: 0
else: 0
message = payload.is_a?(Hash) ? (payload["message"] || payload[:message]) : nil
-
then: 0
else: 0
raw = message if message.present?
-
end
-
-
value = raw.to_s
-
then: 0
else: 0
return value if value.match?(/\A\d+\z/)
-
-
# Some Rails cookie formats base64-encode the message.
-
then: 0
else: 0
if value.match?(/\A[A-Za-z0-9+\/]+=*\z/)
-
decoded = Base64.decode64(value).to_s
-
then: 0
else: 0
return decoded if decoded.match?(/\A\d+\z/)
-
-
# Some formats wrap the signed value as JSON metadata:
-
# {"_rails":{"message":"<base64>","exp":...,"pur":...}}
-
then: 0
else: 0
if decoded.strip.start_with?("{")
-
begin
-
data = JSON.parse(decoded)
-
payload = data["_rails"] || data.dig("_rails")
-
then: 0
else: 0
message = payload.is_a?(Hash) ? payload["message"] : nil
-
then: 0
else: 0
if message.is_a?(String)
-
inner = Base64.decode64(message).to_s
-
then: 0
else: 0
return inner if inner.match?(/\A\d+\z/)
-
end
-
rescue JSON::ParserError
-
# ignore
-
end
-
end
-
end
-
-
nil
-
rescue ArgumentError
-
nil
-
end
-
-
1
def request_authentication
-
session[:return_to_after_authenticating] = request.url
-
redirect_to new_session_path
-
end
-
-
1
def after_authentication_url
-
session.delete(:return_to_after_authenticating) || dashboard_path
-
end
-
-
1
def start_new_session_for(user)
-
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |user_session|
-
Current.session = user_session
-
# Store session id in Rails session for reliability.
-
then: 0
else: 0
session[:auth_session_id] = user_session.id if respond_to?(:session) && session.respond_to?(:[]=)
-
-
# Also store in a signed cookie when available (helps if session store changes later).
-
then: 0
else: 0
then: 0
else: 0
if respond_to?(:cookies) && cookies&.respond_to?(:permanent) && cookies.permanent.respond_to?(:signed)
-
cookies.permanent.signed[:session_id] = user_session.id
-
end
-
end
-
end
-
-
1
def terminate_session
-
Current.session.destroy
-
then: 0
else: 0
session.delete(:auth_session_id) if respond_to?(:session) && session.respond_to?(:delete)
-
then: 0
else: 0
cookies.delete(:session_id) if respond_to?(:cookies) && cookies.respond_to?(:delete)
-
end
-
end
-
# frozen_string_literal: true
-
-
# Concern for pagination functionality using Pagy
-
#
-
# Provides standardized pagination helpers for controllers.
-
# Include this concern in ApplicationController or individual controllers.
-
#
-
# @example
-
# class UsersController < ApplicationController
-
# include Paginatable
-
#
-
# def index
-
# @pagy, @users = pagy(User.all)
-
# end
-
# end
-
#
-
1
module Paginatable
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
1
include Pagy::Backend
-
end
-
end
-
-
# frozen_string_literal: true
-
-
# Controller for the user dashboard
-
#
-
# Provides a minimalistic overview with quick actions, attention items,
-
# recent activity, and pipeline summary.
-
class DashboardController < ApplicationController
-
# GET /dashboard
-
#
-
# Main dashboard view for authenticated users
-
def index
-
@user = Current.user
-
@quick_stats = calculate_quick_stats
-
@needs_attention = calculate_needs_attention
-
@recent_activity = recent_activity_feed
-
@pipeline_summary = pipeline_summary
-
@upcoming_interviews = upcoming_interviews
-
end
-
-
private
-
-
# Calculates quick stats for the dashboard header
-
#
-
# @return [Hash] Stats data
-
def calculate_quick_stats
-
{
-
active_applications: Current.user.interview_applications.not_deleted.where(status: :active).count,
-
total_applications: Current.user.interview_applications.not_deleted.count,
-
interviews_this_week: interviews_this_week_count,
-
emails_to_review: Current.user.synced_emails.needs_review.count,
-
skills_count: Current.user.user_skills.count,
-
resumes_count: Current.user.user_resumes.count
-
}
-
end
-
-
# Calculates items that need user attention
-
#
-
# @return [Array<Hash>] Attention items with type, count, message, and path
-
def calculate_needs_attention
-
items = []
-
-
# Signals needing attention
-
email_count = Current.user.synced_emails.needs_review.count
-
if email_count > 0
-
items << {
-
type: :signals,
-
count: email_count,
-
message: "#{email_count} #{'signal'.pluralize(email_count)} need attention",
-
path: signals_path,
-
icon: "bolt",
-
color: "amber"
-
}
-
end
-
-
# Upcoming interviews this week
-
interview_count = interviews_this_week_count
-
if interview_count > 0
-
items << {
-
type: :interviews,
-
count: interview_count,
-
message: "#{interview_count} #{'interview'.pluralize(interview_count)} this week",
-
path: interview_applications_path,
-
icon: "calendar",
-
color: "blue"
-
}
-
end
-
-
# Stale applications (no activity in 14+ days)
-
stale_count = stale_applications_count
-
if stale_count > 0
-
items << {
-
type: :stale,
-
count: stale_count,
-
message: "#{stale_count} #{'application'.pluralize(stale_count)} need follow-up",
-
path: interview_applications_path,
-
icon: "clock",
-
color: "orange"
-
}
-
end
-
-
# Actionable opportunities (new or reviewing)
-
opportunity_count = Current.user.opportunities.actionable.count
-
if opportunity_count > 0
-
items << {
-
type: :opportunities,
-
count: opportunity_count,
-
message: "#{opportunity_count} new #{'opportunity'.pluralize(opportunity_count)}",
-
path: opportunities_path,
-
icon: "sparkles",
-
color: "purple"
-
}
-
end
-
-
items
-
end
-
-
# Builds recent activity feed
-
#
-
# @return [Array<Hash>] Recent activity items
-
def recent_activity_feed
-
activities = []
-
-
# Recent applications (last 5)
-
Current.user.interview_applications.not_deleted.recent.limit(5).each do |app|
-
activities << {
-
type: :application,
-
title: "Applied to #{app.job_role.title}",
-
subtitle: app.company.name,
-
timestamp: app.applied_at || app.created_at,
-
path: interview_application_path(app),
-
icon: "briefcase"
-
}
-
end
-
-
# Recent interview rounds (last 5)
-
Current.user.interview_rounds
-
.joins(:interview_application)
-
.where(interview_applications: { user_id: Current.user.id, deleted_at: nil })
-
.order(created_at: :desc)
-
.limit(5)
-
.includes(interview_application: [ :company, :job_role ])
-
.each do |round|
-
app = round.interview_application
-
activities << {
-
type: :interview,
-
title: "#{round.stage_display_name} interview",
-
subtitle: "#{app.company.name} - #{app.job_role.title}",
-
timestamp: round.scheduled_at || round.created_at,
-
path: interview_application_path(app),
-
icon: "video-camera"
-
}
-
end
-
-
# Sort by timestamp and take top 5
-
activities.sort_by { |a| a[:timestamp] || Time.at(0) }.reverse.first(5)
-
end
-
-
# Calculates pipeline summary by stage
-
#
-
# @return [Hash] Pipeline counts by stage
-
def pipeline_summary
-
base = Current.user.interview_applications.not_deleted.where(status: :active)
-
-
{
-
applied: base.where(pipeline_stage: :applied).count,
-
screening: base.where(pipeline_stage: :screening).count,
-
interviewing: base.where(pipeline_stage: :interviewing).count,
-
offer: base.where(pipeline_stage: :offer).count,
-
closed: base.where(pipeline_stage: :closed).count,
-
total: base.count
-
}
-
end
-
-
# Returns upcoming interviews (next 7 days)
-
#
-
# @return [ActiveRecord::Relation]
-
def upcoming_interviews
-
Current.user.interview_rounds
-
.joins(:interview_application)
-
.where(interview_applications: { user_id: Current.user.id, deleted_at: nil })
-
.where("scheduled_at > ? AND scheduled_at < ?", Time.current, 7.days.from_now)
-
.where(completed_at: nil)
-
.order(scheduled_at: :asc)
-
.includes(interview_application: [ :company, :job_role ])
-
.limit(5)
-
end
-
-
# Counts interviews scheduled this week
-
#
-
# @return [Integer]
-
def interviews_this_week_count
-
Current.user.interview_rounds
-
.joins(:interview_application)
-
.where(interview_applications: { user_id: Current.user.id, deleted_at: nil })
-
.where("scheduled_at >= ? AND scheduled_at <= ?", Time.current.beginning_of_week, Time.current.end_of_week)
-
.where(completed_at: nil)
-
.count
-
end
-
-
# Counts stale applications (no activity in 14+ days)
-
#
-
# @return [Integer]
-
def stale_applications_count
-
Current.user.interview_applications
-
.not_deleted
-
.where(status: :active)
-
.where("updated_at < ?", 14.days.ago)
-
.count
-
end
-
end
-
class EmailVerificationsController < ApplicationController
-
allow_unauthenticated_access
-
before_action :set_user_by_token, only: :show
-
rate_limit to: 5, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
-
layout "authentication"
-
-
# GET /email_verification/new
-
# Show form to request verification email resend
-
def new
-
@email_address = params[:email_address]
-
end
-
-
# GET /email_verification/:token
-
# Verify user's email address
-
def show
-
if @user.verify_email!
-
# Send welcome email
-
UserMailer.welcome(@user).deliver_later
-
-
redirect_to new_session_path, notice: "Email verified! You can now sign in."
-
else
-
redirect_to new_session_path, alert: "Unable to verify email. Please try again."
-
end
-
end
-
-
# POST /email_verification
-
# Resend verification email
-
def create
-
if user = User.find_by(email_address: params[:email_address])
-
unless user.email_verified?
-
UserMailer.verify_email(user).deliver_later
-
end
-
end
-
-
redirect_to new_session_path, notice: "Verification email sent (if user exists and is not verified)."
-
end
-
-
private
-
def set_user_by_token
-
@user = User.find_by_token_for(:email_verification, params[:token])
-
-
unless @user
-
redirect_to new_session_path, alert: "Email verification link is invalid or has expired."
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# Controller for the intelligent inbox view
-
# Displays synced emails grouped by application with smart filtering
-
# Supports split-pane layout with Turbo Frames
-
class InboxController < ApplicationController
-
before_action :set_synced_email, only: [ :show, :match_application, :ignore ]
-
-
# GET /inbox
-
#
-
# Main inbox view with split-pane layout
-
def index
-
@emails = current_user_emails
-
.includes(:interview_application, :email_sender, interview_application: [ :company, :job_role ])
-
.order(email_date: :desc)
-
-
# Apply relevance filter (default to relevant emails only)
-
@current_relevance = params[:relevance] || "relevant"
-
@emails = filter_by_relevance(@emails)
-
-
# Apply other filters
-
@emails = filter_by_type(@emails)
-
@emails = filter_by_status(@emails)
-
@emails = filter_by_company(@emails)
-
@emails = search_emails(@emails)
-
-
# Group emails by thread for display (showing latest in each thread)
-
@grouped_emails = group_emails_by_application(@emails)
-
-
# Get unmatched emails grouped by thread (only latest email per thread)
-
unmatched_by_thread = group_emails_by_thread(@emails.unmatched)
-
@pagy_unmatched, @unmatched_emails = pagy_array(unmatched_by_thread, limit: 15, page_param: :unmatched_page)
-
-
# Load filter options
-
@email_types = SyncedEmail::EMAIL_TYPES
-
@companies = Company.joins(:interview_applications)
-
.where(interview_applications: { user_id: Current.user.id })
-
.distinct
-
.alphabetical
-
-
# Calculate counts for relevance tabs
-
@relevance_counts = calculate_relevance_counts
-
-
# If email_id param, pre-select that email
-
@selected_email = current_user_emails.find_by(id: params[:email_id]) if params[:email_id]
-
-
# Respond to turbo frame requests for email_list (search/filter without full page reload)
-
respond_to do |format|
-
format.html do
-
if turbo_frame_request_id == "email_list"
-
render inline: <<~ERB, locals: { grouped_emails: @grouped_emails, unmatched_emails: @unmatched_emails, pagy_unmatched: @pagy_unmatched, selected_email_id: @selected_email&.id }
-
<%= turbo_frame_tag "email_list", class: "flex-1 overflow-y-auto" do %>
-
<%= render "inbox/email_list", grouped_emails: grouped_emails, unmatched_emails: unmatched_emails, pagy_unmatched: pagy_unmatched, selected_email_id: selected_email_id %>
-
<% end %>
-
ERB
-
else
-
render :index
-
end
-
end
-
end
-
end
-
-
# GET /inbox/:id
-
#
-
# Show email detail - responds to Turbo Frame for split-pane
-
def show
-
@application = @email.interview_application
-
@thread_emails = @email.thread_emails.includes(:email_sender)
-
-
respond_to do |format|
-
format.html do
-
# Full page render for direct access or mobile
-
render :show
-
end
-
format.turbo_stream do
-
# Turbo Frame update for split-pane
-
render turbo_stream: turbo_stream.update(
-
"email_detail",
-
partial: "inbox/detail_panel",
-
locals: { email: @email, thread_emails: @thread_emails, application: @application }
-
)
-
end
-
end
-
end
-
-
# PATCH /inbox/:id/match_application
-
#
-
# Match email to an interview application
-
def match_application
-
application = Current.user.interview_applications.find_by(id: params[:application_id])
-
-
if application && @email.match_to_application!(application)
-
# Also match other emails in the same thread
-
match_thread_emails(application) if @email.thread_id.present?
-
-
respond_to do |format|
-
format.html { redirect_to inbox_index_path, notice: "Email matched to #{application.company.name}." }
-
format.turbo_stream do
-
@thread_emails = @email.thread_emails.includes(:email_sender)
-
reload_email_list_data
-
render turbo_stream: [
-
turbo_stream.update("email_detail",
-
partial: "inbox/detail_panel",
-
locals: { email: @email, thread_emails: @thread_emails, application: application }
-
),
-
turbo_stream.update("email_list",
-
partial: "inbox/email_list",
-
locals: { grouped_emails: @grouped_emails, unmatched_emails: @unmatched_emails, pagy_unmatched: @pagy_unmatched, selected_email_id: @email.id }
-
),
-
turbo_stream.update("email_stats",
-
html: email_stats_html
-
)
-
]
-
end
-
format.json { render json: { success: true, application_id: application.id } }
-
end
-
else
-
respond_to do |format|
-
format.html { redirect_to inbox_index_path, alert: "Could not match email to application." }
-
format.turbo_stream do
-
render turbo_stream: turbo_stream.update(
-
"email_detail",
-
html: "<div class='p-4 text-red-600'>Could not match email</div>"
-
)
-
end
-
format.json { render json: { success: false }, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# PATCH /inbox/:id/ignore
-
#
-
# Mark email as not interview-related
-
def ignore
-
if @email.ignore!
-
respond_to do |format|
-
format.html { redirect_to inbox_index_path, notice: "Email marked as not interview-related." }
-
format.turbo_stream do
-
reload_email_list_data
-
render turbo_stream: [
-
turbo_stream.update("email_detail",
-
partial: "inbox/empty_state"
-
),
-
turbo_stream.update("email_list",
-
partial: "inbox/email_list",
-
locals: { grouped_emails: @grouped_emails, unmatched_emails: @unmatched_emails, pagy_unmatched: @pagy_unmatched, selected_email_id: nil }
-
),
-
turbo_stream.update("email_stats",
-
html: email_stats_html
-
)
-
]
-
end
-
format.json { render json: { success: true } }
-
end
-
else
-
respond_to do |format|
-
format.html { redirect_to inbox_index_path, alert: "Could not ignore email." }
-
format.turbo_stream do
-
@thread_emails = @email.thread_emails.includes(:email_sender)
-
render turbo_stream: turbo_stream.update(
-
"email_detail",
-
partial: "inbox/detail_panel",
-
locals: { email: @email, thread_emails: @thread_emails, application: @email.interview_application }
-
)
-
end
-
format.json { render json: { success: false }, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
private
-
-
# Sets the email for member actions
-
#
-
# @return [SyncedEmail]
-
def set_synced_email
-
@email = current_user_emails.find(params[:id])
-
rescue ActiveRecord::RecordNotFound
-
respond_to do |format|
-
format.html { redirect_to inbox_index_path, alert: "Email not found." }
-
format.turbo_stream do
-
render turbo_stream: turbo_stream.update(
-
"email_detail",
-
partial: "inbox/empty_state"
-
)
-
end
-
end
-
end
-
-
# Returns the current user's synced emails
-
#
-
# @return [ActiveRecord::Relation]
-
def current_user_emails
-
Current.user.synced_emails
-
end
-
-
# Filters emails by relevance (all, relevant, interviews, opportunities)
-
#
-
# @param emails [ActiveRecord::Relation]
-
# @return [ActiveRecord::Relation]
-
def filter_by_relevance(emails)
-
case @current_relevance
-
when "all"
-
emails.visible # Excludes ignored and auto_ignored
-
when "interviews"
-
emails.interview_related.visible
-
when "opportunities"
-
emails.potential_opportunities.visible
-
else # "relevant" (default)
-
emails.relevant
-
end
-
end
-
-
# Calculates counts for relevance filter tabs
-
#
-
# @return [Hash] Counts by relevance type
-
def calculate_relevance_counts
-
base = current_user_emails
-
{
-
all: base.visible.count,
-
relevant: base.relevant.count,
-
interviews: base.interview_related.visible.count,
-
opportunities: base.potential_opportunities.visible.count
-
}
-
end
-
-
# Filters emails by type
-
#
-
# @param emails [ActiveRecord::Relation]
-
# @return [ActiveRecord::Relation]
-
def filter_by_type(emails)
-
return emails unless params[:type].present?
-
-
emails.by_type(params[:type])
-
end
-
-
# Filters emails by status (matched/unmatched/all)
-
#
-
# @param emails [ActiveRecord::Relation]
-
# @return [ActiveRecord::Relation]
-
def filter_by_status(emails)
-
case params[:status]
-
when "matched"
-
emails.matched
-
when "unmatched"
-
emails.unmatched
-
when "pending"
-
emails.pending
-
when "ignored"
-
emails.ignored
-
else
-
emails
-
end
-
end
-
-
# Filters emails by company
-
#
-
# @param emails [ActiveRecord::Relation]
-
# @return [ActiveRecord::Relation]
-
def filter_by_company(emails)
-
return emails unless params[:company_id].present?
-
-
company = Company.find_by(id: params[:company_id])
-
return emails unless company
-
-
application_ids = Current.user.interview_applications
-
.where(company: company)
-
.pluck(:id)
-
-
emails.where(interview_application_id: application_ids)
-
end
-
-
# Searches emails by query
-
#
-
# @param emails [ActiveRecord::Relation]
-
# @return [ActiveRecord::Relation]
-
def search_emails(emails)
-
return emails unless params[:q].present?
-
-
query = "%#{params[:q]}%"
-
emails.where(
-
"subject ILIKE :q OR from_email ILIKE :q OR from_name ILIKE :q OR snippet ILIKE :q",
-
q: query
-
)
-
end
-
-
# Groups emails by their associated application
-
# Returns the latest email from each thread grouped by application
-
#
-
# @param emails [ActiveRecord::Relation]
-
# @return [Hash]
-
def group_emails_by_application(emails)
-
# Get unique threads, keeping only the latest email from each thread
-
unique_threads = {}
-
emails.matched.each do |email|
-
thread_key = email.thread_id || email.id
-
if unique_threads[thread_key].nil? || (email.email_date && email.email_date > unique_threads[thread_key].email_date)
-
unique_threads[thread_key] = email
-
end
-
end
-
-
# Group by application
-
unique_threads.values
-
.group_by(&:interview_application)
-
.transform_values { |app_emails| app_emails.sort_by { |e| e.email_date || e.created_at }.reverse }
-
.sort_by { |app, _| app&.company&.name || "" }
-
.to_h
-
end
-
-
# Groups emails by thread, keeping only the latest email from each thread
-
#
-
# @param emails [ActiveRecord::Relation]
-
# @return [Array<SyncedEmail>]
-
def group_emails_by_thread(emails)
-
unique_threads = {}
-
emails.each do |email|
-
thread_key = email.thread_id.presence || "single_#{email.id}"
-
if unique_threads[thread_key].nil? || (email.email_date && email.email_date > unique_threads[thread_key].email_date)
-
unique_threads[thread_key] = email
-
end
-
end
-
-
unique_threads.values.sort_by { |e| e.email_date || e.created_at }.reverse
-
end
-
-
# Matches all emails in the same thread to an application
-
#
-
# @param application [InterviewApplication]
-
# @return [void]
-
def match_thread_emails(application)
-
current_user_emails
-
.where(thread_id: @email.thread_id)
-
.where.not(id: @email.id)
-
.update_all(interview_application_id: application.id, status: :processed)
-
end
-
-
# Reloads the email list data for Turbo Stream updates
-
#
-
# @return [void]
-
def reload_email_list_data
-
emails = current_user_emails
-
.includes(:interview_application, :email_sender, interview_application: [ :company, :job_role ])
-
.order(email_date: :desc)
-
-
@grouped_emails = group_emails_by_application(emails)
-
unmatched_by_thread = group_emails_by_thread(emails.unmatched)
-
@pagy_unmatched, @unmatched_emails = pagy_array(unmatched_by_thread, limit: 15, page_param: :unmatched_page)
-
end
-
-
# Returns HTML for the email stats footer
-
#
-
# @return [String]
-
def email_stats_html
-
needs_review = Current.user.synced_emails.unmatched.count
-
matched = Current.user.synced_emails.matched.count
-
"<span>#{needs_review} needs review</span><span>#{matched} matched</span>"
-
end
-
end
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ai
-
# Base controller for the AI Portal
-
class BaseController < Internal::Developer::BaseController
-
helper_method :current_portal
-
-
private
-
-
def current_portal
-
:ai
-
end
-
-
def portal_resources
-
Admin::Base::Resource.resources_for_portal(:ai)
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ai
-
# Dashboard for the AI Portal
-
class DashboardController < BaseController
-
before_action :load_resources!
-
-
# GET /internal/developer/ai
-
def index
-
@resources_by_section = build_resources_by_section
-
@stats = calculate_portal_stats
-
@health = calculate_health_metrics
-
@charts = build_chart_data
-
@recent = build_recent_activity
-
end
-
-
private
-
-
def build_resources_by_section
-
resources = {}
-
-
portal_resources.each do |resource|
-
section = resource.section_name || :general
-
resources[section] ||= []
-
resources[section] << resource
-
end
-
-
resources
-
end
-
-
def calculate_portal_stats
-
{
-
total_resources: portal_resources.count,
-
llm_prompts: ::Ai::LlmPrompt.count,
-
active_prompts: ::Ai::LlmPrompt.where(active: true).count,
-
provider_configs: ::LlmProviderConfig.count,
-
enabled_providers: ::LlmProviderConfig.where(enabled: true).count,
-
api_logs: ::Ai::LlmApiLog.count
-
}
-
end
-
-
def calculate_health_metrics
-
recent_logs = ::Ai::LlmApiLog.where("created_at > ?", 24.hours.ago)
-
total = recent_logs.count
-
successful = recent_logs.where(status: :success).count
-
failed = recent_logs.where(status: :failed).count
-
-
avg_latency = recent_logs.where(status: :success).average(:latency_ms)&.round || 0
-
total_cost_cents = recent_logs.sum(:estimated_cost_cents) || 0
-
total_cost = (total_cost_cents / 100.0).round(2)
-
total_tokens = recent_logs.sum(:total_tokens) || 0
-
-
success_rate = total > 0 ? (successful.to_f / total * 100).round : 100
-
status = if total > 10 && success_rate < 80
-
:critical
-
elsif total > 10 && success_rate < 95
-
:degraded
-
else
-
:healthy
-
end
-
-
{
-
status: status,
-
total: total,
-
successful: successful,
-
failed: failed,
-
success_rate: success_rate,
-
avg_latency: avg_latency,
-
total_cost: total_cost,
-
total_tokens: total_tokens
-
}
-
end
-
-
def build_chart_data
-
# Last 7 days of API calls
-
api_calls_by_day = (0..6).map do |i|
-
date = i.days.ago.to_date
-
count = ::Ai::LlmApiLog.where(created_at: date.beginning_of_day..date.end_of_day).count
-
{ label: date.strftime("%a"), value: count }
-
end.reverse
-
-
# Cost by day (in cents)
-
cost_by_day = (0..6).map do |i|
-
date = i.days.ago.to_date
-
cost_cents = ::Ai::LlmApiLog.where(created_at: date.beginning_of_day..date.end_of_day).sum(:estimated_cost_cents) || 0
-
{ label: date.strftime("%a"), value: cost_cents }
-
end.reverse
-
-
{ api_calls: api_calls_by_day, cost: cost_by_day }
-
end
-
-
def build_recent_activity
-
{
-
api_logs: ::Ai::LlmApiLog.order(created_at: :desc).limit(5),
-
prompts: ::Ai::LlmPrompt.order(updated_at: :desc).limit(5)
-
}
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ai
-
class LlmApiLogsController < Internal::Developer::ResourcesController
-
private
-
-
def resource_config
-
Admin::Resources::LlmApiLogResource
-
end
-
-
def current_portal
-
:ai
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ai
-
# LlmPrompts controller for the AI Portal
-
class LlmPromptsController < Internal::Developer::ResourcesController
-
# POST /internal/developer/ai/llm_prompts/:id/activate
-
def activate
-
if @resource.respond_to?(:activate!) && @resource.activate!
-
redirect_to resource_url(@resource), notice: "Prompt activated."
-
else
-
redirect_to resource_url(@resource), alert: "Failed to activate prompt."
-
end
-
rescue => e
-
redirect_to resource_url(@resource), alert: e.message
-
end
-
-
# POST /internal/developer/ai/llm_prompts/:id/duplicate
-
def duplicate
-
new_prompt = @resource.dup
-
new_prompt.name = "#{@resource.name} (Copy)"
-
new_prompt.active = false
-
new_prompt.version = (@resource.version || 1) + 1
-
-
if new_prompt.save
-
redirect_to resource_url(new_prompt), notice: "Prompt duplicated successfully."
-
else
-
redirect_to resource_url(@resource), alert: "Failed to duplicate prompt."
-
end
-
end
-
-
private
-
-
def current_portal
-
:ai
-
end
-
-
def resource_config
-
Admin::Resources::LlmPromptResource
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ai
-
class LlmProviderConfigsController < Internal::Developer::ResourcesController
-
# POST /internal/developer/ai/llm_provider_configs/:id/toggle
-
def toggle
-
@resource.update!(enabled: !@resource.enabled)
-
-
respond_to do |format|
-
format.turbo_stream do
-
render turbo_stream: turbo_stream.replace(
-
dom_id(@resource, :toggle),
-
partial: "internal/developer/shared/toggle_cell",
-
locals: { record: @resource, field: :enabled }
-
)
-
end
-
format.html { redirect_to resource_url(@resource), notice: "Provider #{@resource.enabled? ? 'enabled' : 'disabled'}." }
-
end
-
end
-
-
# POST /internal/developer/ai/llm_provider_configs/:id/enable
-
def enable
-
@resource.update!(enabled: true)
-
redirect_to resource_url(@resource), notice: "Provider enabled."
-
end
-
-
# POST /internal/developer/ai/llm_provider_configs/:id/disable
-
def disable
-
@resource.update!(enabled: false)
-
redirect_to resource_url(@resource), notice: "Provider disabled."
-
end
-
-
private
-
-
def resource_config
-
Admin::Resources::LlmProviderConfigResource
-
end
-
-
def current_portal
-
:ai
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Assistant
-
# Base controller for the Assistant Portal
-
class BaseController < Internal::Developer::BaseController
-
helper_method :current_portal
-
-
private
-
-
def current_portal
-
:assistant
-
end
-
-
def portal_resources
-
# Include both :ai and :assistant portal resources with assistant section
-
Admin::Base::Resource.registered_resources.select do |r|
-
r.section_name == :assistant
-
end
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Assistant
-
# Dashboard for the Assistant Portal
-
class DashboardController < BaseController
-
before_action :load_resources!
-
-
# GET /internal/developer/assistant
-
def index
-
@resources_by_section = build_resources_by_section
-
@stats = calculate_portal_stats
-
@health = calculate_health_metrics
-
@charts = build_chart_data
-
@recent = build_recent_activity
-
end
-
-
private
-
-
def build_resources_by_section
-
resources = {}
-
-
portal_resources.each do |resource|
-
section = resource.section_name || :general
-
resources[section] ||= []
-
resources[section] << resource
-
end
-
-
resources
-
end
-
-
def calculate_portal_stats
-
{
-
total_resources: portal_resources.count,
-
threads: ::Assistant::ChatThread.count,
-
open_threads: ::Assistant::ChatThread.where(status: "open").count,
-
tool_executions: ::Assistant::ToolExecution.count,
-
pending_approvals: ::Assistant::ToolExecution.where(status: :pending_approval).count,
-
tools: ::Assistant::Tool.count,
-
active_tools: ::Assistant::Tool.where(enabled: true).count,
-
user_memories: ::Assistant::Memory::UserMemory.count
-
}
-
end
-
-
def calculate_health_metrics
-
recent_threads = ::Assistant::ChatThread.where("created_at > ?", 24.hours.ago)
-
recent_executions = ::Assistant::ToolExecution.where("created_at > ?", 24.hours.ago)
-
-
total_executions = recent_executions.count
-
successful = recent_executions.where(status: :completed).count
-
failed = recent_executions.where(status: :failed).count
-
pending = ::Assistant::ToolExecution.where(status: :pending_approval).count
-
-
success_rate = total_executions > 0 ? (successful.to_f / total_executions * 100).round : 100
-
status = if pending > 20 || (total_executions > 10 && success_rate < 70)
-
:degraded
-
elsif failed > 10
-
:critical
-
else
-
:healthy
-
end
-
-
{
-
status: status,
-
threads_24h: recent_threads.count,
-
executions_24h: total_executions,
-
successful: successful,
-
failed: failed,
-
pending: pending,
-
success_rate: success_rate
-
}
-
end
-
-
def build_chart_data
-
# Last 7 days of threads
-
threads_by_day = (0..6).map do |i|
-
date = i.days.ago.to_date
-
count = ::Assistant::ChatThread.where(created_at: date.beginning_of_day..date.end_of_day).count
-
{ label: date.strftime("%a"), value: count }
-
end.reverse
-
-
# Last 7 days of tool executions
-
executions_by_day = (0..6).map do |i|
-
date = i.days.ago.to_date
-
count = ::Assistant::ToolExecution.where(created_at: date.beginning_of_day..date.end_of_day).count
-
{ label: date.strftime("%a"), value: count }
-
end.reverse
-
-
{ threads: threads_by_day, executions: executions_by_day }
-
end
-
-
def build_recent_activity
-
{
-
threads: ::Assistant::ChatThread.includes(:user).order(created_at: :desc).limit(5),
-
executions: ::Assistant::ToolExecution.order(created_at: :desc).limit(5),
-
pending_approvals: ::Assistant::ToolExecution.where(status: :pending_approval).order(created_at: :desc).limit(5)
-
}
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Assistant
-
class EventsController < Internal::Developer::ResourcesController
-
private
-
-
def resource_config
-
Admin::Resources::AssistantEventResource
-
end
-
-
def current_portal
-
:assistant
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Assistant
-
class MemoryProposalsController < Internal::Developer::ResourcesController
-
private
-
-
def resource_config
-
Admin::Resources::AssistantMemoryProposalResource
-
end
-
-
def current_portal
-
:assistant
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Assistant
-
class ThreadSummariesController < Internal::Developer::ResourcesController
-
private
-
-
def resource_config
-
Admin::Resources::AssistantThreadSummaryResource
-
end
-
-
def current_portal
-
:assistant
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Assistant
-
# Threads controller for the Assistant Portal
-
class ThreadsController < Internal::Developer::ResourcesController
-
def export
-
respond_to do |format|
-
format.json { render json: @resource }
-
end
-
end
-
-
private
-
-
def current_portal
-
:assistant
-
end
-
-
def resource_config
-
Admin::Resources::AssistantThreadResource
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Assistant
-
# ToolExecutions controller for the Assistant Portal
-
class ToolExecutionsController < Internal::Developer::ResourcesController
-
# POST /internal/developer/assistant/tool_executions/:id/approve
-
def approve
-
actor = admin_suite_actor
-
unless actor.is_a?(User)
-
redirect_to resource_url(@resource), alert: "Approval requires an authenticated user actor."
-
return
-
end
-
-
if @resource.requires_confirmation && @resource.approved_by_id.nil?
-
@resource.update!(approved_by: actor, approved_at: Time.current)
-
redirect_to resource_url(@resource), notice: "Tool execution approved."
-
else
-
redirect_to resource_url(@resource), alert: "Cannot approve this tool execution."
-
end
-
end
-
-
# POST /internal/developer/assistant/tool_executions/:id/enqueue
-
def enqueue
-
if @resource.status == "proposed" && (!@resource.requires_confirmation || @resource.approved_by_id.present?)
-
@resource.update!(status: "queued")
-
redirect_to resource_url(@resource), notice: "Tool execution enqueued."
-
else
-
redirect_to resource_url(@resource), alert: "Cannot enqueue this tool execution."
-
end
-
end
-
-
# POST /internal/developer/assistant/tool_executions/:id/replay
-
def replay
-
if %w[success error].include?(@resource.status)
-
redirect_to resource_url(@resource), notice: "Tool execution replayed."
-
else
-
redirect_to resource_url(@resource), alert: "Cannot replay this tool execution."
-
end
-
end
-
-
# POST /internal/developer/assistant/tool_executions/bulk_approve
-
def bulk_approve
-
ids = params[:ids] || []
-
actor = admin_suite_actor
-
unless actor.is_a?(User)
-
redirect_to collection_url, alert: "Bulk approval requires an authenticated user actor."
-
return
-
end
-
resource_class.where(id: ids, status: "proposed")
-
.where(requires_confirmation: true, approved_by_id: nil)
-
.update_all(approved_by_id: actor.id, approved_at: Time.current)
-
redirect_to collection_url, notice: "Selected tool executions approved."
-
end
-
-
# POST /internal/developer/assistant/tool_executions/bulk_enqueue
-
def bulk_enqueue
-
ids = params[:ids] || []
-
resource_class.where(id: ids, status: "proposed").update_all(status: "queued")
-
redirect_to collection_url, notice: "Selected tool executions enqueued."
-
end
-
-
def export
-
respond_to do |format|
-
format.json { render json: @resource }
-
end
-
end
-
-
private
-
-
def current_portal
-
:assistant
-
end
-
-
def resource_config
-
Admin::Resources::AssistantToolExecutionResource
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Assistant
-
class ToolsController < Internal::Developer::ResourcesController
-
# POST /internal/developer/assistant/tools/:id/toggle
-
def toggle
-
@resource.update!(enabled: !@resource.enabled)
-
-
respond_to do |format|
-
format.turbo_stream do
-
render turbo_stream: turbo_stream.replace(
-
dom_id(@resource, :toggle),
-
partial: "internal/developer/shared/toggle_cell",
-
locals: { record: @resource, field: :enabled }
-
)
-
end
-
format.html { redirect_to resource_url(@resource), notice: "Tool #{@resource.enabled? ? 'enabled' : 'disabled'}." }
-
end
-
end
-
-
# POST /internal/developer/assistant/tools/:id/enable
-
def enable
-
@resource.update!(enabled: true)
-
redirect_to resource_url(@resource), notice: "Tool enabled."
-
end
-
-
# POST /internal/developer/assistant/tools/:id/disable
-
def disable
-
@resource.update!(enabled: false)
-
redirect_to resource_url(@resource), notice: "Tool disabled."
-
end
-
-
private
-
-
def resource_config
-
Admin::Resources::AssistantToolResource
-
end
-
-
def current_portal
-
:assistant
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Assistant
-
class TurnsController < Internal::Developer::ResourcesController
-
private
-
-
def resource_config
-
Admin::Resources::AssistantTurnResource
-
end
-
-
def current_portal
-
:assistant
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Assistant
-
class UserMemoriesController < Internal::Developer::ResourcesController
-
private
-
-
def resource_config
-
Admin::Resources::AssistantUserMemoryResource
-
end
-
-
def current_portal
-
:assistant
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
# Base controller for the new admin framework at /internal/developer
-
#
-
# Provides common functionality for all developer portal controllers:
-
# - Developer authentication via TechWright SSO
-
# - Layout configuration
-
# - Resource discovery and resolution
-
class BaseController < ApplicationController
-
include ActionView::RecordIdentifier
-
-
# Skip regular user authentication - developers use TechWright SSO
-
allow_unauthenticated_access
-
-
before_action :require_admin!
-
layout "developer"
-
-
helper Internal::Developer::BaseHelper
-
helper_method :current_portal, :navigation_items, :resource_config, :current_developer, :admin_suite_actor
-
-
private
-
-
# Requires developer authentication via TechWright SSO
-
#
-
# @return [void]
-
def require_admin!
-
if defined?(AdminSuite) && AdminSuite.config.authenticate.present?
-
AdminSuite.config.authenticate.call(self)
-
return
-
end
-
-
unless developer_authenticated?
-
redirect_to internal_developer_login_path
-
end
-
end
-
-
# Checks if a developer is currently authenticated
-
#
-
# @return [Boolean]
-
def developer_authenticated?
-
current_developer.present?
-
end
-
-
# Returns the currently authenticated developer
-
#
-
# @return [Developer, nil]
-
def current_developer
-
@current_developer ||= ::Developer.enabled.find_by(id: session[:developer_id])
-
end
-
-
# Returns the configured actor for admin suite actions/auditing/authorization.
-
#
-
# This must not assume `Current.user` because internal tools may use a separate
-
# authentication mechanism (e.g. developer SSO).
-
#
-
# @return [Object, nil]
-
def admin_suite_actor
-
return nil unless defined?(AdminSuite)
-
-
resolver = AdminSuite.config.current_actor
-
resolver&.call(self)
-
rescue StandardError
-
nil
-
end
-
-
# Returns the current portal (ops, ai, or assistant)
-
#
-
# @return [Symbol, nil]
-
def current_portal
-
@current_portal ||= determine_portal
-
end
-
-
# Determines which portal we're in based on the resource
-
#
-
# @return [Symbol, nil]
-
def determine_portal
-
return nil unless resource_config
-
-
resource_config.portal_name
-
end
-
-
# Returns the resource configuration class for the current controller
-
#
-
# @return [Class, nil]
-
def resource_config
-
@resource_config ||= find_resource_config
-
end
-
-
# Finds the resource configuration based on controller name
-
#
-
# @return [Class, nil]
-
def find_resource_config
-
resource_name = controller_name.singularize.camelize
-
"Admin::Resources::#{resource_name}Resource".constantize
-
rescue NameError
-
nil
-
end
-
-
# Returns navigation items grouped by portal and section
-
#
-
# @return [Hash]
-
def navigation_items
-
@navigation_items ||= begin
-
# Ensure all resources are loaded in development
-
load_resources! if Rails.env.development?
-
build_navigation
-
end
-
end
-
-
# Loads all resource files (needed in development mode)
-
#
-
# @return [void]
-
def load_resources!
-
# Skip if already loaded
-
return if Admin::Base::Resource.registered_resources.any?
-
-
globs =
-
if defined?(AdminSuite)
-
AdminSuite.config.resource_globs
-
else
-
[ Rails.root.join("app/admin/resources/*.rb").to_s ]
-
end
-
-
Array(globs).flat_map { |g| Dir[g] }.uniq.each do |file|
-
require file
-
end
-
rescue NameError
-
# Admin::Base::Resource not defined yet, load it first
-
require "admin/base/resource"
-
retry
-
end
-
-
# Builds the navigation structure from registered resources
-
#
-
# @return [Hash]
-
def build_navigation
-
portals =
-
if defined?(AdminSuite)
-
AdminSuite.config.portals
-
else
-
{}
-
end
-
-
navigation = portals.each_with_object({}) do |(key, meta), h|
-
h[key.to_sym] = {
-
label: meta[:label] || key.to_s.humanize,
-
icon: meta[:icon],
-
color: meta[:color],
-
order: meta[:order] || 100,
-
sections: {}
-
}
-
end
-
-
Admin::Base::Resource.registered_resources.each do |resource|
-
next unless resource.portal_name && resource.section_name
-
-
portal = resource.portal_name
-
section = resource.section_name
-
-
navigation[portal] ||= {
-
label: portal.to_s.humanize,
-
icon: nil,
-
color: nil,
-
order: 100,
-
sections: {}
-
}
-
-
navigation[portal][:sections][section] ||= { label: section.to_s.humanize, items: [] }
-
navigation[portal][:sections][section][:items] << {
-
label: resource.human_name_plural,
-
path: "/internal/developer/#{portal}/#{resource.resource_name_plural}",
-
resource: resource
-
}
-
end
-
-
navigation
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
# Dashboard controller for the developer portal
-
#
-
# Provides an overview of all registered resources, health metrics,
-
# and quick access to different portals and sections.
-
class DashboardController < BaseController
-
before_action :load_resources!
-
-
def index
-
@resources_by_portal = build_resources_by_portal
-
@stats = calculate_dashboard_stats
-
@health = calculate_health_metrics
-
@recent_activity = build_recent_activity
-
end
-
-
private
-
-
# Loads all resource files (needed for Zeitwerk lazy loading)
-
#
-
# @return [void]
-
def load_resources!
-
# Skip if already loaded
-
return if Admin::Base::Resource.registered_resources.any?
-
-
# Load all resource definitions (require only loads once)
-
Dir[Rails.root.join("app/admin/resources/*.rb").to_s].each do |file|
-
require file
-
end
-
rescue NameError
-
# Admin::Base::Resource not defined yet, load it first
-
require "admin/base/resource"
-
retry
-
end
-
-
# Groups resources by portal and section
-
#
-
# @return [Hash]
-
def build_resources_by_portal
-
resources = {}
-
-
Admin::Base::Resource.registered_resources.each do |resource|
-
portal = resource.portal_name || :other
-
section = resource.section_name || :general
-
-
resources[portal] ||= {}
-
resources[portal][section] ||= []
-
resources[portal][section] << resource
-
end
-
-
resources
-
end
-
-
# Calculates dashboard statistics
-
#
-
# @return [Hash]
-
def calculate_dashboard_stats
-
{
-
total_resources: Admin::Base::Resource.registered_resources.count,
-
portals: Admin::Base::Resource.registered_resources.map(&:portal_name).uniq.compact.count,
-
ops_resources: Admin::Base::Resource.resources_for_portal(:ops).count,
-
email_resources: Admin::Base::Resource.resources_for_portal(:email).count,
-
ai_resources: Admin::Base::Resource.resources_for_portal(:ai).count,
-
assistant_resources: Admin::Base::Resource.resources_for_portal(:assistant).count
-
}
-
end
-
-
# Calculates health metrics for all systems
-
#
-
# @return [Hash]
-
def calculate_health_metrics
-
{
-
scraping: scraping_health,
-
llm: llm_health,
-
assistant: assistant_health,
-
app: app_health
-
}
-
end
-
-
# Scraping system health
-
def scraping_health
-
recent_attempts = ScrapingAttempt.where("created_at > ?", 24.hours.ago)
-
total = recent_attempts.count
-
successful = recent_attempts.where(status: :completed).count
-
failed = recent_attempts.where(status: :failed).count
-
stuck = recent_attempts.where(status: :processing).where("updated_at < ?", 1.hour.ago).count
-
-
success_rate = total > 0 ? (successful.to_f / total * 100).round : 0
-
status = if stuck > 5 || (total > 10 && success_rate < 50)
-
:critical
-
elsif stuck > 0 || (total > 10 && success_rate < 80)
-
:degraded
-
else
-
:healthy
-
end
-
-
{
-
status: status,
-
metrics: {
-
"24h attempts" => total,
-
"success rate" => "#{success_rate}%",
-
"failed" => failed,
-
"stuck" => stuck
-
}
-
}
-
end
-
-
# LLM API health
-
def llm_health
-
recent_logs = ::Ai::LlmApiLog.where("created_at > ?", 24.hours.ago)
-
total = recent_logs.count
-
successful = recent_logs.where(status: :success).count
-
failed = recent_logs.where(status: :failed).count
-
-
# Calculate average latency
-
avg_latency = recent_logs.where(status: :success).average(:latency_ms)&.round || 0
-
total_cost_cents = recent_logs.sum(:estimated_cost_cents) || 0
-
total_cost = (total_cost_cents / 100.0).round(2)
-
-
success_rate = total > 0 ? (successful.to_f / total * 100).round : 0
-
status = if total > 10 && success_rate < 80
-
:critical
-
elsif total > 10 && success_rate < 95
-
:degraded
-
else
-
:healthy
-
end
-
-
{
-
status: status,
-
metrics: {
-
"24h calls" => total,
-
"success rate" => "#{success_rate}%",
-
"avg latency" => "#{avg_latency}ms",
-
"24h cost" => "$#{total_cost}"
-
}
-
}
-
end
-
-
# Assistant system health
-
def assistant_health
-
recent_threads = ::Assistant::ChatThread.where("created_at > ?", 24.hours.ago)
-
recent_executions = ::Assistant::ToolExecution.where("created_at > ?", 24.hours.ago)
-
-
total_threads = recent_threads.count
-
total_executions = recent_executions.count
-
pending_approvals = ::Assistant::ToolExecution.where(status: :pending_approval).count
-
failed_executions = recent_executions.where(status: :failed).count
-
-
status = if pending_approvals > 20 || failed_executions > 10
-
:degraded
-
else
-
:healthy
-
end
-
-
{
-
status: status,
-
metrics: {
-
"24h threads" => total_threads,
-
"24h tool runs" => total_executions,
-
"pending approval" => pending_approvals,
-
"failed" => failed_executions
-
}
-
}
-
end
-
-
# Overall app health
-
def app_health
-
{
-
status: :healthy,
-
metrics: {
-
"users" => User.count,
-
"24h signups" => User.where("created_at > ?", 24.hours.ago).count,
-
"applications" => InterviewApplication.count,
-
"job listings" => JobListing.enabled.count
-
}
-
}
-
end
-
-
# Build recent activity feed
-
#
-
# @return [Hash]
-
def build_recent_activity
-
{
-
recent_users: User.order(created_at: :desc).limit(5),
-
recent_applications: InterviewApplication.includes(:user, :company).order(created_at: :desc).limit(5),
-
recent_threads: ::Assistant::ChatThread.includes(:user).order(created_at: :desc).limit(5),
-
recent_scraping: ScrapingAttempt.order(created_at: :desc).limit(5)
-
}
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
# Developer portal documentation viewer
-
#
-
# Provides a two-panel layout for browsing docs from Rails.root/docs
-
class DocsController < BaseController
-
DOCS_ROOT = Rails.root.join("docs").freeze
-
-
# GET /internal/developer/docs
-
# GET /internal/developer/docs?path=ASSISTANT_OVERVIEW.md
-
def index
-
@files = grouped_markdown_files
-
@selected_path = params[:path].presence
-
-
if @selected_path.present?
-
load_doc_content(@selected_path)
-
elsif @files.values.flatten.any?
-
# Auto-select the first doc if none specified
-
@selected_path = @files.values.flatten.first
-
load_doc_content(@selected_path)
-
end
-
end
-
-
# GET /internal/developer/docs/:path (for direct linking)
-
def show
-
relative_path = params[:path].to_s
-
file_path = resolve_doc_path!(relative_path)
-
-
@files = grouped_markdown_files
-
@selected_path = relative_path
-
@title = File.basename(file_path, ".md").tr("_", " ").tr("-", " ").titleize
-
@raw_markdown = File.read(file_path)
-
-
rendered = MarkdownRenderer.new(@raw_markdown).render
-
@content_html = rendered[:html]
-
@toc = rendered[:toc]
-
@reading_time = rendered[:reading_time_minutes]
-
-
render :index
-
rescue ActiveRecord::RecordNotFound
-
redirect_to internal_developer_docs_path, alert: "Doc not found."
-
end
-
-
private
-
-
def load_doc_content(relative_path)
-
file_path = resolve_doc_path!(relative_path)
-
@title = File.basename(file_path, ".md").tr("_", " ").tr("-", " ").titleize
-
@raw_markdown = File.read(file_path)
-
-
rendered = MarkdownRenderer.new(@raw_markdown).render
-
@content_html = rendered[:html]
-
@toc = rendered[:toc]
-
@reading_time = rendered[:reading_time_minutes]
-
rescue ActiveRecord::RecordNotFound
-
@title = nil
-
@content_html = nil
-
@toc = []
-
@reading_time = nil
-
end
-
-
def grouped_markdown_files
-
base = docs_root_realpath
-
files = Dir.glob(base.join("**/*.md")).sort.map do |abs|
-
abs_path = Pathname.new(abs)
-
abs_path.relative_path_from(base).to_s
-
end
-
-
groups = files.group_by { |path| group_name_for_path(path) }
-
-
# Sort groups with preferred order, then alphabetically
-
preferred_order = [
-
"Overview",
-
"CICD",
-
"Developer Portal",
-
"Billing",
-
"Google Integration",
-
"Testing",
-
"Assistant",
-
"Admin UI",
-
"Features",
-
"Other"
-
]
-
-
groups.sort_by { |k, _| [ preferred_order.index(k) || 999, k ] }.to_h
-
end
-
-
def group_name_for_path(relative_path)
-
# Folder-based grouping: docs/<folder>/* -> "<Folder>" section
-
folder = relative_path.to_s.split(File::SEPARATOR).first
-
if folder.present? && folder != File.basename(relative_path.to_s)
-
return humanize_folder_name(folder)
-
end
-
-
# Root files (docs/*.md): keep legacy prefix grouping for backward compatibility
-
legacy_group_for_basename(File.basename(relative_path.to_s, ".md"))
-
end
-
-
def legacy_group_for_basename(name)
-
if name == "README"
-
"Overview"
-
elsif name.start_with?("ASSISTANT_")
-
"Assistant"
-
elsif name.start_with?("ADMIN_")
-
"Admin UI"
-
elsif name.start_with?("GOOGLE_")
-
"Google Integration"
-
elsif name.start_with?("TEST")
-
"Testing"
-
elsif name.start_with?("DEVELOPER_PORTAL_")
-
"Developer Portal"
-
elsif name.include?("BILLING") || name.include?("SUBSCRIPTION")
-
"Billing"
-
else
-
"Other"
-
end
-
end
-
-
def humanize_folder_name(folder)
-
normalized = folder.to_s.tr("_", " ").tr("-", " ").strip
-
acronyms = {
-
"cicd" => "CICD",
-
"ci cd" => "CICD",
-
"ai" => "AI",
-
"ops" => "Ops",
-
"oauth" => "OAuth",
-
"ui" => "UI",
-
"ux" => "UX",
-
"api" => "API"
-
}
-
-
key = normalized.downcase
-
return acronyms[key] if acronyms.key?(key)
-
-
normalized.titleize
-
end
-
-
def resolve_doc_path!(relative_path)
-
raise ActiveRecord::RecordNotFound if relative_path.blank?
-
raise ActiveRecord::RecordNotFound if relative_path.include?("..")
-
-
base = docs_root_realpath
-
candidate = base.join(relative_path)
-
raise ActiveRecord::RecordNotFound unless candidate.extname == ".md"
-
-
real = candidate.realpath
-
raise ActiveRecord::RecordNotFound unless real.to_s.start_with?(base.to_s + File::SEPARATOR)
-
-
real.to_s
-
rescue Errno::ENOENT, Errno::EACCES
-
raise ActiveRecord::RecordNotFound
-
end
-
-
def docs_root_realpath
-
DOCS_ROOT.realpath
-
rescue Errno::ENOENT
-
DOCS_ROOT
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Email
-
# Base controller for the Email Portal.
-
class BaseController < Internal::Developer::BaseController
-
helper_method :current_portal
-
-
private
-
-
def current_portal
-
:email
-
end
-
-
def portal_resources
-
Admin::Base::Resource.resources_for_portal(:email)
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Email
-
# Dashboard for the Email Portal (Signals pipeline observability).
-
class DashboardController < BaseController
-
before_action :load_resources!
-
-
# GET /internal/developer/email
-
def index
-
@stats = calculate_portal_stats
-
@health = calculate_health_metrics
-
@recent = build_recent_activity
-
end
-
-
private
-
-
def calculate_portal_stats
-
{
-
total_emails: SyncedEmail.count,
-
unmatched: SyncedEmail.unmatched.count,
-
matched: SyncedEmail.matched.count,
-
needs_review: SyncedEmail.needs_review.count,
-
pipeline_runs_24h: Signals::EmailPipelineRun.where("created_at > ?", 24.hours.ago).count,
-
pipeline_events_24h: Signals::EmailPipelineEvent.where("created_at > ?", 24.hours.ago).count
-
}
-
end
-
-
def calculate_health_metrics
-
recent_runs = Signals::EmailPipelineRun.where("created_at > ?", 24.hours.ago)
-
total = recent_runs.count
-
successful = recent_runs.where(status: :success).count
-
failed = recent_runs.where(status: :failed).count
-
running = recent_runs.where(status: :started).count
-
-
success_rate = total.positive? ? (successful.to_f / total * 100).round : 0
-
avg_duration = recent_runs.where.not(duration_ms: nil).average(:duration_ms)&.round || 0
-
-
status =
-
if failed > 10 || (total > 20 && success_rate < 80)
-
:critical
-
elsif failed.positive? || (total > 20 && success_rate < 95) || running > 20
-
:degraded
-
else
-
:healthy
-
end
-
-
{
-
status: status,
-
metrics: {
-
"24h runs" => total,
-
"success rate" => "#{success_rate}%",
-
"failed" => failed,
-
"running" => running,
-
"avg duration" => "#{avg_duration}ms"
-
}
-
}
-
end
-
-
def build_recent_activity
-
{
-
emails: SyncedEmail.order(email_date: :desc).limit(8),
-
runs: Signals::EmailPipelineRun.order(created_at: :desc).limit(8)
-
}
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Email
-
class EmailPipelineEventsController < Internal::Developer::ResourcesController
-
private
-
-
def resource_config
-
Admin::Resources::EmailPipelineEventResource
-
end
-
-
def current_portal
-
:email
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Email
-
class EmailPipelineRunsController < Internal::Developer::ResourcesController
-
private
-
-
def resource_config
-
Admin::Resources::EmailPipelineRunResource
-
end
-
-
def current_portal
-
:email
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Email
-
class SyncedEmailsController < Internal::Developer::ResourcesController
-
private
-
-
def resource_config
-
Admin::Resources::SyncedEmailResource
-
end
-
-
def current_portal
-
:email
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ops
-
# Base controller for the Ops Portal
-
class BaseController < Internal::Developer::BaseController
-
helper_method :current_portal
-
-
private
-
-
def current_portal
-
:ops
-
end
-
-
def portal_resources
-
Admin::Base::Resource.resources_for_portal(:ops)
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ops
-
class BlogPostsController < Internal::Developer::ResourcesController
-
before_action :set_blog_post, only: %i[publish unpublish]
-
-
# POST /internal/developer/ops/blog_posts/:id/publish
-
def publish
-
if @blog_post.update(status: :published, published_at: @blog_post.published_at || Time.current)
-
redirect_to internal_developer_ops_blog_post_path(@blog_post), notice: "Blog post published successfully."
-
else
-
redirect_to internal_developer_ops_blog_post_path(@blog_post), alert: "Failed to publish blog post."
-
end
-
end
-
-
# POST /internal/developer/ops/blog_posts/:id/unpublish
-
def unpublish
-
if @blog_post.update(status: :draft)
-
redirect_to internal_developer_ops_blog_post_path(@blog_post), notice: "Blog post unpublished successfully."
-
else
-
redirect_to internal_developer_ops_blog_post_path(@blog_post), alert: "Failed to unpublish blog post."
-
end
-
end
-
-
private
-
-
def set_blog_post
-
@blog_post = BlogPost.friendly.find(params[:id])
-
end
-
-
def resource_config
-
Admin::Resources::BlogPostResource
-
end
-
-
def current_portal
-
:ops
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ops
-
# Categories controller for the Ops Portal
-
class CategoriesController < Internal::Developer::ResourcesController
-
def disable
-
@resource.update(disabled: true) if @resource.respond_to?(:disabled=)
-
redirect_to resource_url(@resource), notice: "Category disabled."
-
end
-
-
def enable
-
@resource.update(disabled: false) if @resource.respond_to?(:disabled=)
-
redirect_to resource_url(@resource), notice: "Category enabled."
-
end
-
-
def merge
-
@merge_candidates = resource_class.where.not(id: @resource.id).order(:name).limit(100)
-
end
-
-
def merge_into
-
target = resource_class.find(params[:target_id])
-
-
result = Category.merge_categories(@resource, target)
-
-
if result[:success]
-
redirect_to resource_url(target), notice: "Categories merged successfully. #{result[:message]}"
-
else
-
redirect_to merge_internal_developer_ops_category_path(@resource), alert: result[:error]
-
end
-
rescue => e
-
Rails.logger.error("Category merge failed: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
-
redirect_to merge_internal_developer_ops_category_path(@resource), alert: "Merge failed: #{e.message}"
-
end
-
-
private
-
-
def current_portal
-
:ops
-
end
-
-
def resource_config
-
Admin::Resources::CategoryResource
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ops
-
# Companies controller for the Ops Portal
-
class CompaniesController < Internal::Developer::ResourcesController
-
def disable
-
@resource.update(disabled: true) if @resource.respond_to?(:disabled=)
-
redirect_to resource_url(@resource), notice: "Company disabled."
-
end
-
-
def enable
-
@resource.update(disabled: false) if @resource.respond_to?(:disabled=)
-
redirect_to resource_url(@resource), notice: "Company enabled."
-
end
-
-
def merge
-
@merge_candidates = resource_class.where.not(id: @resource.id).order(:name).limit(100)
-
end
-
-
def merge_into
-
target = resource_class.find(params[:target_id])
-
-
result = Company.merge_companies(@resource, target)
-
-
if result[:success]
-
redirect_to resource_url(target), notice: "Companies merged successfully. #{result[:message]}"
-
else
-
redirect_to merge_internal_developer_ops_company_path(@resource), alert: result[:error]
-
end
-
rescue => e
-
Rails.logger.error("Company merge failed: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
-
redirect_to merge_internal_developer_ops_company_path(@resource), alert: "Merge failed: #{e.message}"
-
end
-
-
private
-
-
def current_portal
-
:ops
-
end
-
-
def resource_config
-
Admin::Resources::CompanyResource
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ops
-
# CompanyFeedbacks controller for the Ops Portal
-
class CompanyFeedbacksController < Internal::Developer::ResourcesController
-
private
-
-
def current_portal
-
:ops
-
end
-
-
def resource_config
-
Admin::Resources::CompanyFeedbackResource
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ops
-
class ConnectedAccountsController < Internal::Developer::ResourcesController
-
private
-
-
def resource_config
-
Admin::Resources::ConnectedAccountResource
-
end
-
-
def current_portal
-
:ops
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ops
-
# Dashboard for the Ops Portal
-
class DashboardController < BaseController
-
before_action :load_resources!
-
-
# GET /internal/developer/ops
-
def index
-
@resources_by_section = build_resources_by_section
-
@stats = calculate_portal_stats
-
@health = calculate_health_metrics
-
@recent = build_recent_activity
-
@charts = build_chart_data
-
end
-
-
private
-
-
def build_resources_by_section
-
resources = {}
-
-
portal_resources.each do |resource|
-
section = resource.section_name || :general
-
resources[section] ||= []
-
resources[section] << resource
-
end
-
-
resources
-
end
-
-
def calculate_portal_stats
-
{
-
total_resources: portal_resources.count,
-
companies: Company.count,
-
job_roles: JobRole.count,
-
categories: Category.count,
-
skill_tags: SkillTag.count,
-
users: User.count,
-
applications: InterviewApplication.count,
-
job_listings: JobListing.count
-
}
-
end
-
-
def calculate_health_metrics
-
{
-
scraping: scraping_health,
-
email_sync: email_sync_health
-
}
-
end
-
-
def scraping_health
-
recent = ScrapingAttempt.where("created_at > ?", 24.hours.ago)
-
total = recent.count
-
completed = recent.where(status: :completed).count
-
failed = recent.where(status: :failed).count
-
stuck = recent.where(status: :processing).where("updated_at < ?", 30.minutes.ago).count
-
-
rate = total > 0 ? (completed.to_f / total * 100).round : 0
-
status = if stuck > 5 || (total > 10 && rate < 50)
-
:critical
-
elsif stuck > 0 || (total > 10 && rate < 80)
-
:degraded
-
else
-
:healthy
-
end
-
-
{ status: status, total: total, completed: completed, failed: failed, stuck: stuck, rate: rate }
-
end
-
-
def email_sync_health
-
recent = SyncedEmail.where("created_at > ?", 24.hours.ago)
-
total = recent.count
-
pending = SyncedEmail.where(status: :needs_review).count
-
processed = recent.where(status: :processed).count
-
-
{ total: total, pending: pending, processed: processed }
-
end
-
-
def build_recent_activity
-
{
-
users: User.order(created_at: :desc).limit(5),
-
applications: InterviewApplication.includes(:user, :company).order(created_at: :desc).limit(5),
-
job_listings: JobListing.includes(:company).order(created_at: :desc).limit(5),
-
scraping: ScrapingAttempt.order(created_at: :desc).limit(5)
-
}
-
end
-
-
def build_chart_data
-
# Last 7 days of scraping attempts
-
scraping_by_day = (0..6).map do |i|
-
date = i.days.ago.to_date
-
count = ScrapingAttempt.where(created_at: date.beginning_of_day..date.end_of_day).count
-
{ label: date.strftime("%a"), value: count }
-
end.reverse
-
-
# Last 7 days of signups
-
signups_by_day = (0..6).map do |i|
-
date = i.days.ago.to_date
-
count = User.where(created_at: date.beginning_of_day..date.end_of_day).count
-
{ label: date.strftime("%a"), value: count }
-
end.reverse
-
-
{ scraping: scraping_by_day, signups: signups_by_day }
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ops
-
class EmailSendersController < Internal::Developer::ResourcesController
-
private
-
-
def resource_config
-
Admin::Resources::EmailSenderResource
-
end
-
-
def current_portal
-
:ops
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ops
-
class HtmlScrapingLogsController < Internal::Developer::ResourcesController
-
private
-
-
def resource_config
-
Admin::Resources::HtmlScrapingLogResource
-
end
-
-
def current_portal
-
:ops
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ops
-
class InterviewApplicationsController < Internal::Developer::ResourcesController
-
private
-
-
def resource_config
-
Admin::Resources::InterviewApplicationResource
-
end
-
-
def current_portal
-
:ops
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ops
-
# InterviewRoundTypes controller for the Ops Portal
-
#
-
# Provides CRUD and toggle operations for managing interview round types.
-
class InterviewRoundTypesController < Internal::Developer::ResourcesController
-
# POST /internal/developer/ops/interview_round_types/:id/disable
-
def disable
-
@resource.disable!
-
redirect_to resource_url(@resource), notice: "Round type disabled."
-
end
-
-
# POST /internal/developer/ops/interview_round_types/:id/enable
-
def enable
-
@resource.enable!
-
redirect_to resource_url(@resource), notice: "Round type enabled."
-
end
-
-
# POST /internal/developer/ops/interview_round_types/:id/toggle
-
def toggle
-
if @resource.disabled?
-
@resource.enable!
-
redirect_to resource_url(@resource), notice: "Round type enabled."
-
else
-
@resource.disable!
-
redirect_to resource_url(@resource), notice: "Round type disabled."
-
end
-
end
-
-
private
-
-
def current_portal
-
:ops
-
end
-
-
def resource_config
-
Admin::Resources::InterviewRoundTypeResource
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ops
-
# InterviewRounds controller for the Ops Portal
-
class InterviewRoundsController < Internal::Developer::ResourcesController
-
private
-
-
def current_portal
-
:ops
-
end
-
-
def resource_config
-
Admin::Resources::InterviewRoundResource
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ops
-
class JobListingsController < Internal::Developer::ResourcesController
-
private
-
-
def resource_config
-
Admin::Resources::JobListingResource
-
end
-
-
def current_portal
-
:ops
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ops
-
# JobRoles controller for the Ops Portal
-
class JobRolesController < Internal::Developer::ResourcesController
-
def disable
-
@resource.update(disabled: true) if @resource.respond_to?(:disabled=)
-
redirect_to resource_url(@resource), notice: "Job role disabled."
-
end
-
-
def enable
-
@resource.update(disabled: false) if @resource.respond_to?(:disabled=)
-
redirect_to resource_url(@resource), notice: "Job role enabled."
-
end
-
-
def merge
-
@merge_candidates = resource_class.where.not(id: @resource.id).order(:title).limit(100)
-
end
-
-
def merge_into
-
target = resource_class.find(params[:target_id])
-
-
result = JobRole.merge_job_roles(@resource, target)
-
-
if result[:success]
-
redirect_to resource_url(target), notice: "Job roles merged successfully. #{result[:message]}"
-
else
-
redirect_to merge_internal_developer_ops_job_role_path(@resource), alert: result[:error]
-
end
-
rescue => e
-
Rails.logger.error("JobRole merge failed: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
-
redirect_to merge_internal_developer_ops_job_role_path(@resource), alert: "Merge failed: #{e.message}"
-
end
-
-
private
-
-
def current_portal
-
:ops
-
end
-
-
def resource_config
-
Admin::Resources::JobRoleResource
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ops
-
class ScrapingAttemptsController < Internal::Developer::ResourcesController
-
private
-
-
def resource_config
-
Admin::Resources::ScrapingAttemptResource
-
end
-
-
def current_portal
-
:ops
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ops
-
class ScrapingEventsController < Internal::Developer::ResourcesController
-
private
-
-
def resource_config
-
Admin::Resources::ScrapingEventResource
-
end
-
-
def current_portal
-
:ops
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ops
-
# Settings controller for the Ops Portal
-
class SettingsController < Internal::Developer::ResourcesController
-
# POST /internal/developer/ops/settings/:id/toggle
-
def toggle
-
@resource.update!(value: !@resource.value)
-
-
respond_to do |format|
-
format.turbo_stream do
-
render turbo_stream: turbo_stream.replace(
-
dom_id(@resource, :toggle),
-
partial: "internal/developer/shared/toggle_cell",
-
locals: { record: @resource, field: :value }
-
)
-
end
-
format.html { redirect_to resource_url(@resource), notice: "Setting toggled." }
-
end
-
end
-
-
private
-
-
def resource_config
-
Admin::Resources::SettingResource
-
end
-
-
def current_portal
-
:ops
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ops
-
# SkillTags controller for the Ops Portal
-
class SkillTagsController < Internal::Developer::ResourcesController
-
def disable
-
@resource.update(disabled: true) if @resource.respond_to?(:disabled=)
-
redirect_to resource_url(@resource), notice: "Skill tag disabled."
-
end
-
-
def enable
-
@resource.update(disabled: false) if @resource.respond_to?(:disabled=)
-
redirect_to resource_url(@resource), notice: "Skill tag enabled."
-
end
-
-
def merge
-
@merge_candidates = resource_class.where.not(id: @resource.id).order(:name).limit(100)
-
end
-
-
def merge_into
-
target = resource_class.find(params[:target_id])
-
-
result = SkillTag.merge_skills(@resource, target)
-
-
if result[:success]
-
redirect_to resource_url(target), notice: "Skill tags merged successfully. #{result[:message]}"
-
else
-
redirect_to merge_internal_developer_ops_skill_tag_path(@resource), alert: result[:error]
-
end
-
rescue => e
-
Rails.logger.error("Merge failed: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
-
redirect_to merge_internal_developer_ops_skill_tag_path(@resource), alert: "Merge failed: #{e.message}"
-
end
-
-
private
-
-
def current_portal
-
:ops
-
end
-
-
def resource_config
-
Admin::Resources::SkillTagResource
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ops
-
class SupportTicketsController < Internal::Developer::ResourcesController
-
private
-
-
def resource_config
-
Admin::Resources::SupportTicketResource
-
end
-
-
def current_portal
-
:ops
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ops
-
class SyncedEmailsController < Internal::Developer::ResourcesController
-
private
-
-
def resource_config
-
Admin::Resources::SyncedEmailResource
-
end
-
-
def current_portal
-
:ops
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Ops
-
class UsersController < Internal::Developer::ResourcesController
-
# POST /internal/developer/ops/users/:id/resend_verification_email
-
# Resends the email verification link to the user
-
def resend_verification_email
-
if resource.email_verified?
-
redirect_to resource_url(resource), alert: "User email is already verified."
-
else
-
UserMailer.verify_email(resource).deliver_later
-
redirect_to resource_url(resource), notice: "Verification email sent to #{resource.email_address}."
-
end
-
end
-
-
# POST /internal/developer/ops/users/:id/grant_admin
-
# Grants admin privileges to the user
-
def grant_admin
-
resource.update!(is_admin: true)
-
redirect_to resource_url(resource), notice: "Granted admin privileges to #{resource.display_name}."
-
end
-
-
# POST /internal/developer/ops/users/:id/revoke_admin
-
# Revokes admin privileges from the user
-
def revoke_admin
-
actor = admin_suite_actor
-
if actor.is_a?(User) && resource == actor
-
redirect_to resource_url(resource), alert: "You cannot revoke your own admin privileges."
-
else
-
resource.update!(is_admin: false)
-
redirect_to resource_url(resource), notice: "Revoked admin privileges from #{resource.display_name}."
-
end
-
end
-
-
# POST /internal/developer/ops/users/:id/grant_billing_admin_access
-
def grant_billing_admin_access
-
actor = admin_suite_actor
-
Billing::AdminAccessService.new(user: resource, actor: (actor.is_a?(User) ? actor : nil)).grant!
-
redirect_to resource_url(resource), notice: "Granted Admin/Developer billing access."
-
end
-
-
# POST /internal/developer/ops/users/:id/revoke_billing_admin_access
-
def revoke_billing_admin_access
-
actor = admin_suite_actor
-
Billing::AdminAccessService.new(user: resource, actor: (actor.is_a?(User) ? actor : nil)).revoke!
-
redirect_to resource_url(resource), notice: "Revoked Admin/Developer billing access."
-
end
-
-
private
-
-
def resource_config
-
Admin::Resources::UserResource
-
end
-
-
def current_portal
-
:ops
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Payments
-
# Dashboard for the Payments Portal.
-
class DashboardController < Internal::Developer::BaseController
-
before_action :load_resources!
-
-
# GET /internal/developer/payments
-
def index
-
@stats = {
-
plans: Billing::Plan.count,
-
features: Billing::Feature.count,
-
entitlements: Billing::PlanEntitlement.count,
-
mappings: Billing::ProviderMapping.count,
-
subscriptions: Billing::Subscription.count,
-
webhook_events_pending: Billing::WebhookEvent.where(status: "pending").count
-
}
-
end
-
-
private
-
-
def current_portal
-
:payments
-
end
-
end
-
end
-
end
-
end
-
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Payments
-
# Features controller for the Payments Portal.
-
class FeaturesController < Internal::Developer::ResourcesController
-
private
-
-
def current_portal
-
:payments
-
end
-
-
def resource_config
-
Admin::Resources::BillingFeatureResource
-
end
-
end
-
end
-
end
-
end
-
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Payments
-
# Plan entitlements controller for the Payments Portal.
-
class PlanEntitlementsController < Internal::Developer::ResourcesController
-
private
-
-
def current_portal
-
:payments
-
end
-
-
def resource_config
-
Admin::Resources::BillingPlanEntitlementResource
-
end
-
end
-
end
-
end
-
end
-
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Payments
-
# Plans controller for the Payments Portal.
-
class PlansController < Internal::Developer::ResourcesController
-
private
-
-
def current_portal
-
:payments
-
end
-
-
def resource_config
-
Admin::Resources::BillingPlanResource
-
end
-
end
-
end
-
end
-
end
-
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Payments
-
# Provider mappings controller for the Payments Portal.
-
class ProviderMappingsController < Internal::Developer::ResourcesController
-
private
-
-
def current_portal
-
:payments
-
end
-
-
def resource_config
-
Admin::Resources::BillingProviderMappingResource
-
end
-
end
-
end
-
end
-
end
-
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Payments
-
# Subscriptions controller for the Payments Portal (read-only).
-
class SubscriptionsController < Internal::Developer::ResourcesController
-
private
-
-
def current_portal
-
:payments
-
end
-
-
def resource_config
-
Admin::Resources::BillingSubscriptionResource
-
end
-
end
-
end
-
end
-
end
-
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
module Payments
-
# Webhook events controller for the Payments Portal (read-only + replay).
-
class WebhookEventsController < Internal::Developer::ResourcesController
-
# POST /internal/developer/payments/webhook_events/:id/replay
-
def replay
-
@resource.update!(status: "pending", processed_at: nil, error_message: nil)
-
Billing::ProcessWebhookEventJob.perform_later(@resource)
-
redirect_to resource_url(@resource), notice: "Webhook event replay enqueued."
-
end
-
-
private
-
-
def current_portal
-
:payments
-
end
-
-
def resource_config
-
Admin::Resources::BillingWebhookEventResource
-
end
-
end
-
end
-
end
-
end
-
-
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
# Generic resources controller for the admin framework
-
#
-
# Provides CRUD operations for any resource defined with Admin::Base::Resource.
-
# Can be inherited by specific resource controllers or used directly.
-
class ResourcesController < BaseController
-
include Pagy::Backend
-
include Pagy::Frontend
-
include Rails.application.routes.url_helpers
-
-
before_action :set_resource, if: -> { params[:id].present? && action_name != "index" && action_name != "new" && action_name != "create" }
-
-
helper_method :resource_class, :collection, :resource
-
-
protected
-
-
# Override view prefixes to also look in resources/ folder for shared views
-
# This allows specific controllers to have their own views, falling back to shared ones
-
def _prefixes
-
@_prefixes ||= super + [ "internal/developer/resources" ]
-
end
-
-
public
-
-
# GET /internal/developer/:resources
-
def index
-
@stats = calculate_stats if resource_config&.index_config&.stats_list&.any?
-
@pagy, @collection = paginate_collection(filtered_collection)
-
end
-
-
# GET /internal/developer/:resources/:id
-
def show
-
end
-
-
# GET /internal/developer/:resources/new
-
def new
-
@resource = resource_class.new
-
end
-
-
# GET /internal/developer/:resources/:id/edit
-
def edit
-
end
-
-
# POST /internal/developer/:resources
-
def create
-
@resource = resource_class.new(resource_params)
-
-
if @resource.save
-
redirect_to resource_url(@resource), notice: "#{resource_config.human_name} was successfully created."
-
else
-
render :new, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH/PUT /internal/developer/:resources/:id
-
def update
-
if @resource.update(resource_params)
-
redirect_to resource_url(@resource), notice: "#{resource_config.human_name} was successfully updated."
-
else
-
render :edit, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /internal/developer/:resources/:id
-
def destroy
-
@resource.destroy!
-
redirect_to collection_url, notice: "#{resource_config.human_name} was successfully deleted."
-
end
-
-
# POST /internal/developer/:resources/:id/execute_action/:action_name
-
def execute_action
-
action_name = params[:action_name].to_sym
-
action_def = find_action(action_name)
-
-
if action_def.nil?
-
redirect_to resource_url(@resource), alert: "Action not found."
-
return
-
end
-
-
executor = Admin::Base::ActionExecutor.new(resource_config, action_name, admin_suite_actor)
-
result = executor.execute_member(@resource, params.to_unsafe_h)
-
-
if result.success?
-
redirect_to resource_url(@resource), notice: result.message
-
else
-
redirect_to resource_url(@resource), alert: result.message
-
end
-
end
-
-
# POST /internal/developer/:portal/:resource_name/:id/toggle
-
def toggle
-
field = toggle_field_param
-
-
unless field
-
respond_to do |format|
-
format.turbo_stream { head :unprocessable_entity }
-
format.html { redirect_to resource_url(@resource), alert: "Toggle field is missing." }
-
end
-
return
-
end
-
-
unless toggleable_fields.include?(field)
-
respond_to do |format|
-
format.turbo_stream { head :unprocessable_entity }
-
format.html { redirect_to resource_url(@resource), alert: "Toggle field is not allowed." }
-
end
-
return
-
end
-
-
current_value = !!@resource.public_send(field)
-
@resource.update!(field => !current_value)
-
-
respond_to do |format|
-
format.turbo_stream do
-
render turbo_stream: turbo_stream.replace(
-
dom_id(@resource, :toggle),
-
partial: "internal/developer/shared/toggle_cell",
-
locals: { record: @resource, field: field }
-
)
-
end
-
format.html { redirect_to resource_url(@resource), notice: "#{resource_config.human_name} updated." }
-
end
-
end
-
-
# POST /internal/developer/:resources/bulk_action/:action_name
-
def bulk_action
-
action_name = params[:action_name].to_sym
-
ids = params[:ids] || []
-
-
if ids.empty?
-
redirect_to collection_url, alert: "No items selected."
-
return
-
end
-
-
model = resource_class
-
if ids.all? { |id| uuid_param?(id) } && model.column_names.include?("uuid")
-
records = model.where(uuid: ids)
-
else
-
records = model.where(id: ids)
-
end
-
executor = Admin::Base::ActionExecutor.new(resource_config, action_name, admin_suite_actor)
-
result = executor.execute_bulk(records, params.to_unsafe_h)
-
-
if result.success?
-
redirect_to collection_url, notice: result.message
-
else
-
redirect_to collection_url, alert: result.message
-
end
-
end
-
-
protected
-
-
# Resolves the resource configuration.
-
#
-
# For normal resource controllers (e.g. `Internal::Developer::Ops::UsersController`),
-
# `BaseController#resource_config` uses `controller_name`.
-
#
-
# For the generic action routes:
-
# `/internal/developer/:portal/:resource_name/:id/execute_action/:action_name`,
-
# we need to resolve based on `params[:resource_name]`.
-
#
-
# @return [Class, nil]
-
def resource_config
-
return super unless params[:resource_name].present?
-
-
resource_name = params[:resource_name].to_s.singularize.camelize
-
"Admin::Resources::#{resource_name}Resource".constantize
-
rescue NameError
-
nil
-
end
-
-
# Returns the model class for the resource
-
#
-
# @return [Class]
-
def resource_class
-
resource_config&.model_class || controller_name.classify.constantize
-
end
-
-
# Returns the current resource instance
-
#
-
# @return [ActiveRecord::Base]
-
def resource
-
@resource
-
end
-
-
# Returns the current collection
-
#
-
# @return [ActiveRecord::Relation]
-
def collection
-
@collection
-
end
-
-
private
-
-
# Sets the resource from params
-
#
-
# @return [void]
-
def set_resource
-
model = resource_config.model_class
-
@resource = find_resource(model, params[:id])
-
end
-
-
def find_resource(model, param)
-
if uuid_param?(param) && model.column_names.include?("uuid")
-
model.find_by!(uuid: param)
-
else
-
model.find(param)
-
end
-
end
-
-
def uuid_param?(value)
-
value.to_s.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
-
end
-
-
# Returns the base collection with any default scopes
-
#
-
# @return [ActiveRecord::Relation]
-
def base_collection
-
resource_config.model_class.all
-
end
-
-
# Applies filters and search to the collection
-
#
-
# @return [ActiveRecord::Relation]
-
def filtered_collection
-
return base_collection unless resource_config&.index_config
-
-
Admin::Base::FilterBuilder.new(resource_config, params).apply(base_collection)
-
end
-
-
# Paginates the collection
-
#
-
# @param scope [ActiveRecord::Relation] Collection to paginate
-
# @return [Array<Pagy, ActiveRecord::Relation>]
-
def paginate_collection(scope)
-
per_page = resource_config&.index_config&.per_page || 25
-
pagy(scope, items: per_page)
-
end
-
-
# Calculates stats for the index view
-
#
-
# @return [Array<Hash>]
-
def calculate_stats
-
resource_config.index_config.stats_list.map do |stat_def|
-
# Stats procs don't take arguments - they query directly
-
value = begin
-
stat_def.calculator.call
-
rescue => e
-
Rails.logger.error("Error calculating stat #{stat_def.name}: #{e.message}")
-
"N/A"
-
end
-
-
{
-
name: stat_def.name.to_s.humanize,
-
value: value,
-
color: stat_def.color
-
}
-
end
-
end
-
-
# Finds an action definition by name
-
#
-
# @param name [Symbol] Action name
-
# @return [ActionDefinition, nil]
-
def find_action(name)
-
resource_config&.actions_config&.member_actions&.find { |a| a.name == name }
-
end
-
-
# Returns permitted parameters based on form configuration
-
#
-
# @return [ActionController::Parameters]
-
def resource_params
-
permitted_fields = []
-
array_fields = []
-
-
resource_config&.form_config&.fields_list&.each do |field|
-
next if field.is_a?(Admin::Base::Resource::SectionDefinition) ||
-
field.is_a?(Admin::Base::Resource::SectionEnd) ||
-
field.is_a?(Admin::Base::Resource::RowDefinition) ||
-
field.is_a?(Admin::Base::Resource::RowEnd)
-
-
# Tags and multi-select fields need to be permitted as arrays
-
if field.type == :tags || field.type == :multi_select
-
array_fields << { field.name => [] }
-
# Also permit tag_list for :tags type
-
array_fields << { tag_list: [] } if field.type == :tags && field.name != :tag_list
-
else
-
permitted_fields << field.name
-
end
-
end
-
-
# Handle STI: the form is built from the concrete record class (e.g. Ai::AssistantSystemPrompt)
-
# but the resource config may be the base class (e.g. Ai::LlmPrompt).
-
param_keys = [
-
(@resource&.class&.model_name&.param_key if defined?(@resource)),
-
resource_class.model_name.param_key
-
].compact.uniq
-
-
key = param_keys.find { |k| params.key?(k) }
-
params.require(key).permit(permitted_fields + array_fields)
-
end
-
-
def toggle_field_param
-
field = params[:field].presence
-
field&.to_sym
-
end
-
-
def toggleable_fields
-
return [] unless resource_config&.index_config&.columns_list
-
-
resource_config.index_config.columns_list.filter_map do |column|
-
next unless column.type == :toggle
-
-
(column.toggle_field || column.name).to_sym
-
end
-
end
-
-
# Returns the URL for a resource
-
#
-
# @param record [ActiveRecord::Base] Record
-
# @return [String]
-
def resource_url(record)
-
url_for(controller: resource_controller_path, action: :show, id: record.to_param)
-
end
-
-
# Returns the URL for the collection
-
#
-
# @return [String]
-
def collection_url
-
url_for(controller: resource_controller_path, action: :index)
-
end
-
-
# Returns the correct controller path for redirects when using generic routes.
-
#
-
# @return [String]
-
def resource_controller_path
-
if params[:portal].present? && params[:resource_name].present?
-
"/internal/developer/#{params[:portal]}/#{params[:resource_name]}"
-
else
-
controller_path
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Internal
-
module Developer
-
# Controller for developer portal authentication via TechWright SSO
-
#
-
# Handles login, OAuth callbacks, and logout for the admin portal.
-
# Developers authenticate separately from regular users.
-
class SessionsController < ApplicationController
-
# Skip regular user authentication - developers use TechWright SSO
-
allow_unauthenticated_access
-
# Skip CSRF for OAuth callbacks (they come from external provider)
-
skip_before_action :verify_authenticity_token, only: [ :create, :failure ]
-
-
layout "developer_login"
-
-
# GET /internal/developer/login
-
#
-
# Shows the developer login page with TechWright SSO button
-
def new
-
redirect_to internal_developer_root_path if developer_authenticated?
-
end
-
-
# GET /internal
-
#
-
# Redirects to developer portal if signed in, otherwise to login
-
def redirect_root
-
if developer_authenticated?
-
redirect_to internal_developer_root_path
-
else
-
redirect_to internal_developer_login_path
-
end
-
end
-
-
# GET /auth/failure (for TechWright)
-
#
-
# Handles TechWright OAuth authentication failures
-
def failure
-
error_message = params[:message] || "unknown error"
-
Rails.logger.warn "TechWright OAuth failure: #{error_message}"
-
-
redirect_to internal_developer_login_path,
-
alert: "Authentication failed: #{error_message.humanize}. Please try again."
-
end
-
-
# GET/POST /auth/techwright/callback
-
#
-
# Handles TechWright OAuth callback, creates or updates developer record
-
def create
-
auth = request.env["omniauth.auth"]
-
-
if auth.nil?
-
redirect_to internal_developer_login_path, alert: "Authentication failed. Please try again."
-
return
-
end
-
-
developer = ::Developer.find_or_create_from_omniauth(auth)
-
-
unless developer.enabled?
-
redirect_to internal_developer_login_path,
-
alert: "Your developer access has been disabled."
-
return
-
end
-
-
developer.record_login!(ip_address: request.remote_ip)
-
session[:developer_id] = developer.id
-
-
redirect_to internal_developer_root_path,
-
notice: "Welcome, #{developer.name || developer.email}!"
-
end
-
-
# DELETE /internal/developer/logout
-
#
-
# Signs out the developer and optionally revokes the OAuth token
-
def destroy
-
revoke_token if current_developer&.access_token.present?
-
session.delete(:developer_id)
-
-
redirect_to internal_developer_login_path,
-
notice: "Signed out successfully.", status: :see_other
-
end
-
-
private
-
-
# Revokes the OAuth token at TechWright
-
#
-
# @return [void]
-
def revoke_token
-
return unless current_developer&.access_token.present?
-
-
# Use token_site for server-side requests (supports devcontainer setups)
-
site = Rails.application.credentials.dig(:techwright, :token_site) ||
-
Rails.application.credentials.dig(:techwright, :site) ||
-
"https://techwright.io"
-
uri = URI("#{site}/oauth/revoke")
-
http = Net::HTTP.new(uri.host, uri.port)
-
http.use_ssl = uri.scheme == "https"
-
-
request = Net::HTTP::Post.new(uri)
-
request.set_form_data(
-
token: current_developer.access_token,
-
client_id: Rails.application.credentials.dig(:techwright, :client_id),
-
client_secret: Rails.application.credentials.dig(:techwright, :client_secret)
-
)
-
-
http.request(request)
-
rescue StandardError => e
-
Rails.logger.error("TechWright token revocation failed: #{e.message}")
-
end
-
-
# Checks if a developer is currently authenticated
-
#
-
# @return [Boolean]
-
def developer_authenticated?
-
current_developer.present?
-
end
-
-
# Returns the currently authenticated developer
-
#
-
# @return [Developer, nil]
-
def current_developer
-
@current_developer ||= ::Developer.enabled.find_by(id: session[:developer_id])
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
class InterviewApplicationPrepsController < ApplicationController
-
before_action :set_application
-
-
# POST /interview_applications/:interview_application_id/prep/refresh
-
def refresh
-
ent = Billing::Entitlements.for(Current.user)
-
unless ent.allowed?(:interview_prepare_access)
-
redirect_to interview_application_path(@application, tab: "prepare"),
-
alert: "Upgrade to unlock full Prepare",
-
status: :see_other
-
return
-
end
-
-
remaining = ent.remaining(:interview_prepare_refreshes)
-
if remaining.is_a?(Integer) && remaining <= 0
-
redirect_to interview_application_path(@application, tab: "prepare"),
-
alert: "You’ve reached your monthly refresh limit",
-
status: :see_other
-
return
-
end
-
-
GenerateInterviewPrepPackJob.perform_later(@application, user: Current.user)
-
-
redirect_to interview_application_path(@application, tab: "prepare"),
-
notice: "Generating prep…",
-
status: :see_other
-
end
-
-
private
-
-
def set_application
-
@application = Current.user.interview_applications.not_deleted.find(params[:interview_application_id])
-
rescue ActiveRecord::RecordNotFound
-
redirect_to interview_applications_path, alert: "Application not found"
-
end
-
end
-
# frozen_string_literal: true
-
-
# Controller for managing interview application tracking
-
class InterviewApplicationsController < ApplicationController
-
before_action :set_application, only: [ :show, :edit, :update, :update_pipeline_stage, :update_job_description, :archive, :reject, :accept, :reactivate ]
-
before_action :set_application_for_soft_delete, only: [ :destroy ]
-
before_action :set_deleted_application, only: [ :restore ]
-
before_action :set_view_preference, only: [ :index, :kanban ]
-
-
# GET /interview_applications
-
def index
-
scope = Current.user.interview_applications
-
-
@status_counts = scope.not_deleted.group(:status).count
-
@deleted_count = scope.deleted.count
-
-
base_applications = scope
-
.includes(:company, :job_role, :job_listing, :skill_tags, :interview_rounds)
-
-
if params[:status] == "deleted"
-
base_applications = base_applications.deleted
-
else
-
base_applications = base_applications.not_deleted
-
base_applications = base_applications.where(status: params[:status]) if params[:status].present?
-
end
-
-
# Apply search
-
if params[:q].present?
-
search_term = "%#{params[:q].strip}%"
-
base_applications = base_applications
-
.left_outer_joins(:company, :job_role)
-
.where(
-
"companies.name ILIKE :q OR job_roles.title ILIKE :q",
-
q: search_term
-
)
-
end
-
-
# Apply filters
-
base_applications = base_applications.where(pipeline_stage: params[:pipeline_stage]) if params[:pipeline_stage].present?
-
-
if params[:date_from].present?
-
begin
-
base_applications = base_applications.where("applied_at >= ?", Date.parse(params[:date_from]))
-
rescue ArgumentError
-
# Invalid date format, ignore filter
-
end
-
end
-
-
if params[:date_to].present?
-
begin
-
base_applications = base_applications.where("applied_at <= ?", Date.parse(params[:date_to]))
-
rescue ArgumentError
-
# Invalid date format, ignore filter
-
end
-
end
-
-
# Apply sorting
-
case params[:sort]
-
when "company"
-
base_applications = base_applications.joins(:company).order("companies.name ASC")
-
when "company_desc"
-
base_applications = base_applications.joins(:company).order("companies.name DESC")
-
when "role"
-
base_applications = base_applications.joins(:job_role).order("job_roles.title ASC")
-
when "role_desc"
-
base_applications = base_applications.joins(:job_role).order("job_roles.title DESC")
-
when "date"
-
base_applications = base_applications.order("applied_at ASC, created_at ASC")
-
when "date_desc"
-
base_applications = base_applications.order("applied_at DESC, created_at DESC")
-
else
-
base_applications = base_applications.recent
-
end
-
-
# Paginate for table view, load all for kanban
-
if @current_view == "kanban"
-
@applications = base_applications.to_a
-
# Group by pipeline stage for kanban columns
-
@applications_by_pipeline_stage = InterviewApplication::PIPELINE_STAGES.index_with do |stage|
-
@applications.select { |app| app.pipeline_stage == stage.to_s }
-
end
-
else
-
@pagy, @applications = pagy(base_applications, limit: 20)
-
end
-
end
-
-
# GET /interview_applications/kanban
-
def kanban
-
@applications = Current.user.interview_applications
-
.includes(:company, :job_role, :interview_rounds)
-
.not_deleted
-
.active
-
.recent
-
-
@applications_by_pipeline_stage = InterviewApplication::PIPELINE_STAGES.index_with do |stage|
-
@applications.select { |app| app.pipeline_stage == stage.to_s }
-
end
-
end
-
-
# GET /interview_applications/:id
-
def show
-
@interview_rounds = @application.interview_rounds.ordered
-
@next_upcoming_round = @application.interview_rounds.upcoming.order(scheduled_at: :asc).first
-
@company_feedback = @application.company_feedback
-
@synced_emails = @application.synced_emails.recent
-
@application_timeline = ApplicationTimelineService.new(@application)
-
@extracted_company_careers_url = @synced_emails
-
.find { |email| email.signal_company_careers_url.present? }
-
&.signal_company_careers_url
-
@extracted_action_links = build_extracted_action_links(@synced_emails)
-
@join_interview_link = build_join_interview_link(@next_upcoming_round, @extracted_action_links)
-
@reschedule_link = build_reschedule_link(@extracted_action_links)
-
-
@prep_artifacts_by_kind = @application.interview_prep_artifacts.recent_first.index_by(&:kind)
-
focus = @prep_artifacts_by_kind["focus_areas"]&.content
-
focus_items = Array(focus&.dig("focus_areas")).filter_map { |i| i.is_a?(Hash) ? i["title"] : nil }
-
@prep_focus_areas_preview = focus_items.first(3)
-
end
-
-
# GET /interview_applications/new
-
def new
-
@application = Current.user.interview_applications.build
-
@companies = Company.alphabetical.limit(100)
-
@job_roles = JobRole.alphabetical.limit(100)
-
end
-
-
# GET /interview_applications/:id/edit
-
def edit
-
@companies = Company.alphabetical.limit(100)
-
@job_roles = JobRole.alphabetical.limit(100)
-
end
-
-
# POST /interview_applications
-
def create
-
@application = Current.user.interview_applications.build(application_params)
-
-
# Set defaults (AASM will set initial states, but we ensure applied_at is set)
-
@application.applied_at ||= Date.today
-
-
if @application.save
-
# Create job listing from URL if provided
-
if params[:interview_application][:job_listing_url].present?
-
CreateJobListingFromUrlService.new(@application, params[:interview_application][:job_listing_url]).call
-
end
-
-
respond_to do |format|
-
format.html { redirect_to interview_applications_path, notice: "Application added successfully!" }
-
format.turbo_stream { redirect_to interview_applications_path, notice: "Application added successfully!", status: :see_other }
-
end
-
else
-
@companies = Company.alphabetical.limit(100)
-
@job_roles = JobRole.alphabetical.limit(100)
-
respond_to do |format|
-
format.html { render :new, status: :unprocessable_entity }
-
format.turbo_stream { render :new, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# PATCH/PUT /interview_applications/:id
-
def update
-
if @application.update(application_params)
-
job_listing_url = params.dig(:interview_application, :job_listing_url)
-
if job_listing_url.present?
-
CreateJobListingFromUrlService.new(@application, job_listing_url).call
-
end
-
redirect_to interview_applications_path, notice: "Application updated successfully!"
-
else
-
@companies = Company.alphabetical.limit(100)
-
@job_roles = JobRole.alphabetical.limit(100)
-
render :edit, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH /interview_applications/:id/update_job_description
-
def update_job_description
-
if @application.update(job_description_params)
-
redirect_to interview_application_path(@application, tab: "prepare"),
-
notice: "Job description saved",
-
status: :see_other
-
else
-
redirect_to interview_application_path(@application, tab: "prepare"),
-
alert: "Could not save job description",
-
status: :see_other
-
end
-
end
-
-
# DELETE /interview_applications/:id
-
def destroy
-
if @application.soft_delete!
-
notice = "Application deleted. You can restore it within 3 months."
-
-
# If the delete happened from the show page, redirecting back would hit a now-unreachable URL.
-
if request.referer&.match?(%r{/applications/[^/?]+$})
-
redirect_to interview_applications_path, notice:, status: :see_other
-
else
-
redirect_back fallback_location: interview_applications_path, notice:, status: :see_other
-
end
-
else
-
redirect_back fallback_location: interview_application_path(@application), alert: "Could not delete application", status: :see_other
-
end
-
end
-
-
# PATCH /interview_applications/:id/update_pipeline_stage
-
def update_pipeline_stage
-
target_stage = params[:pipeline_stage]&.to_sym
-
-
# Map target stage to appropriate AASM event
-
event_method = case target_stage
-
when :screening
-
:move_to_screening
-
when :interviewing
-
:move_to_interviewing
-
when :offer
-
:move_to_offer
-
when :closed
-
:move_to_closed
-
when :applied
-
:move_to_applied
-
else
-
nil
-
end
-
-
if event_method && @application.aasm(:pipeline_stage).may_fire_event?(event_method)
-
if @application.send("#{event_method}!")
-
notice = "Moved to #{target_stage.to_s.titleize}"
-
respond_to do |format|
-
format.html { redirect_back fallback_location: interview_applications_path, notice: notice, status: :see_other }
-
format.turbo_stream { redirect_back fallback_location: interview_applications_path, notice: notice, status: :see_other }
-
format.json { render json: { success: true, pipeline_stage: @application.pipeline_stage } }
-
end
-
else
-
respond_to do |format|
-
format.html { redirect_back fallback_location: interview_applications_path, alert: "Failed to update stage", status: :see_other }
-
format.turbo_stream { redirect_back fallback_location: interview_applications_path, alert: "Failed to update stage", status: :see_other }
-
format.json { render json: { success: false, errors: @application.errors }, status: :unprocessable_entity }
-
end
-
end
-
else
-
respond_to do |format|
-
format.html { redirect_back fallback_location: interview_applications_path, alert: "Invalid stage transition", status: :see_other }
-
format.turbo_stream { redirect_back fallback_location: interview_applications_path, alert: "Invalid stage transition", status: :see_other }
-
format.json { render json: { success: false, errors: [ "Invalid stage transition" ] }, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# PATCH /interview_applications/:id/archive
-
def archive
-
if @application.may_archive? && @application.archive!
-
redirect_back fallback_location: interview_application_path(@application), notice: "Application archived", status: :see_other
-
else
-
redirect_back fallback_location: interview_application_path(@application), alert: "Cannot archive this application", status: :see_other
-
end
-
end
-
-
# PATCH /interview_applications/:id/reject
-
def reject
-
if @application.may_reject? && @application.reject!
-
redirect_back fallback_location: interview_application_path(@application), notice: "Application marked as rejected", status: :see_other
-
else
-
redirect_back fallback_location: interview_application_path(@application), alert: "Cannot reject this application", status: :see_other
-
end
-
end
-
-
# PATCH /interview_applications/:id/accept
-
def accept
-
if @application.may_accept? && @application.accept!
-
redirect_back fallback_location: interview_application_path(@application), notice: "Congratulations! Application marked as accepted", status: :see_other
-
else
-
redirect_back fallback_location: interview_application_path(@application), alert: "Cannot accept this application", status: :see_other
-
end
-
end
-
-
# PATCH /interview_applications/:id/reactivate
-
def reactivate
-
if @application.may_reactivate? && @application.reactivate!
-
redirect_back fallback_location: interview_application_path(@application), notice: "Application reactivated", status: :see_other
-
else
-
redirect_back fallback_location: interview_application_path(@application), alert: "Cannot reactivate this application", status: :see_other
-
end
-
end
-
-
# PATCH /interview_applications/:id/restore
-
def restore
-
if @application.restore!
-
redirect_back fallback_location: interview_applications_path, notice: "Application restored", status: :see_other
-
else
-
redirect_back fallback_location: interview_applications_path(status: "deleted"), alert: "Could not restore application", status: :see_other
-
end
-
end
-
-
# POST /interview_applications/quick_apply
-
def quick_apply
-
url = params[:url]&.strip
-
-
if url.blank?
-
respond_to do |format|
-
format.json { render json: { success: false, error: "URL is required" }, status: :unprocessable_entity }
-
format.html { redirect_to interview_applications_path, alert: "URL is required" }
-
end
-
return
-
end
-
-
service = QuickApplyFromUrlService.new(url, Current.user)
-
result = service.call
-
-
if result[:success]
-
application = result[:application]
-
-
respond_to do |format|
-
format.json do
-
render json: {
-
success: true,
-
application: {
-
id: application.id,
-
slug: application.slug,
-
company_name: application.company.name,
-
job_role_title: application.job_role.title,
-
url: interview_application_path(application)
-
},
-
message: "Application created successfully!"
-
}
-
end
-
format.html do
-
redirect_to interview_application_path(application), notice: "Application created successfully!"
-
end
-
format.turbo_stream do
-
flash.now[:notice] = "Application created successfully!"
-
render turbo_stream: [
-
turbo_stream.replace("flash", partial: "shared/flash"),
-
turbo_stream.redirect_to(interview_application_path(application))
-
]
-
end
-
end
-
else
-
error_message = result[:error] || "Failed to create application"
-
-
respond_to do |format|
-
format.json do
-
render json: {
-
success: false,
-
error: error_message
-
}, status: :unprocessable_entity
-
end
-
format.html do
-
redirect_to interview_applications_path, alert: error_message
-
end
-
format.turbo_stream do
-
flash.now[:alert] = error_message
-
render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash")
-
end
-
end
-
end
-
rescue => e
-
Rails.logger.error("Quick apply failed: #{e.message}")
-
Rails.logger.error(e.backtrace.join("\n"))
-
-
respond_to do |format|
-
format.json do
-
render json: {
-
success: false,
-
error: "An error occurred: #{e.message}"
-
}, status: :internal_server_error
-
end
-
format.html do
-
redirect_to interview_applications_path, alert: "An error occurred. Please try again."
-
end
-
format.turbo_stream do
-
flash.now[:alert] = "An error occurred. Please try again."
-
render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash")
-
end
-
end
-
end
-
-
private
-
-
# Builds a de-duplicated list of action links extracted from emails
-
#
-
# @param emails [Enumerable<SyncedEmail>]
-
# @return [Array<Hash>]
-
def build_extracted_action_links(emails)
-
require "set"
-
seen = Set.new
-
-
Array(emails).flat_map(&:action_links).each_with_object([]) do |link, links|
-
url = link["url"].to_s.strip
-
next if url.blank? || seen.include?(url)
-
-
seen.add(url)
-
links << link
-
end
-
end
-
-
# Picks a join interview link from rounds or extracted links
-
#
-
# @param next_round [InterviewRound, nil]
-
# @param links [Array<Hash>]
-
# @return [String, nil]
-
def build_join_interview_link(next_round, links)
-
return next_round.video_link if next_round&.video_link.present?
-
-
join_link = links.find do |link|
-
label = link["action_label"].to_s.downcase
-
label.include?("join") || label.include?("zoom") || label.include?("meet")
-
end
-
-
join_link&.dig("url")
-
end
-
-
# Picks a reschedule/scheduling link from extracted links
-
#
-
# @param links [Array<Hash>]
-
# @return [String, nil]
-
def build_reschedule_link(links)
-
link = links.find do |item|
-
label = item["action_label"].to_s.downcase
-
label.include?("reschedule") || label.include?("schedule") || label.include?("book")
-
end
-
-
link&.dig("url")
-
end
-
-
def set_application
-
@application = Current.user.interview_applications.not_deleted.find(params[:id])
-
rescue ActiveRecord::RecordNotFound
-
redirect_to interview_applications_path, alert: "Application not found"
-
end
-
-
def set_application_for_soft_delete
-
@application = Current.user.interview_applications.not_deleted.find(params[:id])
-
rescue ActiveRecord::RecordNotFound
-
redirect_to interview_applications_path, alert: "Application not found"
-
end
-
-
def set_deleted_application
-
@application = Current.user.interview_applications.deleted.find(params[:id])
-
rescue ActiveRecord::RecordNotFound
-
redirect_to interview_applications_path(status: "deleted"), alert: "Deleted application not found"
-
end
-
-
def set_view_preference
-
# Get view from params or user preference
-
view = params[:view] || Current.user.preference.preferred_view
-
# Normalize view names: "list" -> "table"
-
@current_view = (view == "list") ? "table" : view
-
@current_view = "table" if params[:status] == "deleted"
-
end
-
-
def application_params
-
params.expect(interview_application: [
-
:company_id,
-
:job_role_id,
-
:applied_at,
-
:notes,
-
:job_description_text
-
])
-
end
-
-
def job_description_params
-
params.expect(interview_application: [ :job_description_text ])
-
end
-
end
-
# frozen_string_literal: true
-
-
# Controller for managing self-reflection feedback for interview rounds
-
class InterviewFeedbacksController < ApplicationController
-
before_action :set_interview_round
-
before_action :set_interview_feedback, only: [ :edit, :update, :destroy ]
-
-
# GET /interview_applications/:interview_application_id/interview_rounds/:interview_round_id/interview_feedback/new
-
def new
-
@feedback = @round.interview_feedback || @round.build_interview_feedback
-
end
-
-
# POST /interview_applications/:interview_application_id/interview_rounds/:interview_round_id/interview_feedback
-
def create
-
@feedback = @round.build_interview_feedback(feedback_params)
-
-
if @feedback.save
-
trial_result = maybe_unlock_insight_trial_after_feedback
-
notice = "Self-reflection added successfully!"
-
if trial_result[:unlocked]
-
notice = "#{notice} You’ve unlocked Pro insights for 72 hours."
-
end
-
redirect_to interview_application_path(@round.interview_application), notice: notice
-
else
-
render :new, status: :unprocessable_entity
-
end
-
end
-
-
# GET /interview_applications/:interview_application_id/interview_rounds/:interview_round_id/interview_feedback/edit
-
def edit
-
end
-
-
# PATCH/PUT /interview_applications/:interview_application_id/interview_rounds/:interview_round_id/interview_feedback
-
def update
-
if @feedback.update(feedback_params)
-
redirect_to interview_application_path(@round.interview_application), notice: "Self-reflection updated successfully!"
-
else
-
render :edit, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /interview_applications/:interview_application_id/interview_rounds/:interview_round_id/interview_feedback
-
def destroy
-
@feedback.destroy
-
redirect_to interview_application_path(@round.interview_application), notice: "Self-reflection deleted successfully!"
-
end
-
-
private
-
-
def set_interview_round
-
@round = Current.user.interview_applications
-
.find(params[:interview_application_id])
-
.interview_rounds
-
.find(params[:interview_round_id])
-
rescue ActiveRecord::RecordNotFound
-
redirect_to interview_applications_path, alert: "Interview round not found"
-
end
-
-
def set_interview_feedback
-
@feedback = @round.interview_feedback
-
redirect_to interview_application_path(@round.interview_application), alert: "Self-reflection not found" unless @feedback
-
end
-
-
def feedback_params
-
params.require(:interview_feedback).permit(
-
:went_well,
-
:to_improve,
-
:self_reflection,
-
:interviewer_notes,
-
:tag_list
-
)
-
end
-
-
# Unlocks the insight-triggered trial when the user has uploaded a CV and is adding their first feedback entry.
-
#
-
# @return [Hash] Trial unlock result
-
def maybe_unlock_insight_trial_after_feedback
-
user = Current.user
-
return { unlocked: false } if user.nil?
-
return { unlocked: false } unless user.user_resumes.exists?
-
-
feedback_count = InterviewFeedback
-
.joins(interview_round: { interview_application: :user })
-
.where(users: { id: user.id })
-
.count
-
return { unlocked: false } unless feedback_count == 1
-
-
Billing::TrialUnlockService.new(
-
user: user,
-
trigger: :first_feedback_after_cv,
-
metadata: { feedback_count: feedback_count }
-
).run
-
end
-
end
-
# frozen_string_literal: true
-
-
# Controller for managing interview round preparation content
-
class InterviewRoundPrepsController < ApplicationController
-
before_action :set_application
-
before_action :set_round
-
-
# GET /interview_applications/:interview_application_id/interview_rounds/:interview_round_id/prep
-
# Shows the prep content for a specific round
-
def show
-
@prep = @round.prep
-
@entitlements = Billing::Entitlements.for(Current.user)
-
end
-
-
# POST /interview_applications/:interview_application_id/interview_rounds/:interview_round_id/prep/generate
-
# Generates or regenerates prep content for a round
-
def generate
-
ent = Billing::Entitlements.for(Current.user)
-
-
# Check access
-
unless ent.allowed?(:round_prep_access)
-
redirect_to interview_application_path(@application, anchor: "rounds"),
-
alert: "Round prep requires a Pro or Sprint subscription."
-
return
-
end
-
-
# Check quota
-
remaining = ent.remaining(:round_prep_generations)
-
if remaining.is_a?(Integer) && remaining <= 0
-
redirect_to interview_application_path(@application, anchor: "rounds"),
-
alert: "You've used all your round prep generations for this month."
-
return
-
end
-
-
@artifact = InterviewRoundPrepArtifact.find_or_initialize_for(
-
interview_round: @round,
-
kind: :comprehensive
-
)
-
-
# Enqueue the job for background generation
-
GenerateRoundPrepJob.perform_later(@round)
-
-
respond_to do |format|
-
format.html do
-
redirect_to interview_application_path(@application, anchor: "rounds"),
-
notice: "Generating interview prep for #{@round.stage_display_name}..."
-
end
-
format.turbo_stream do
-
flash.now[:notice] = "Generating interview prep..."
-
end
-
end
-
end
-
-
# GET /interview_applications/:interview_application_id/interview_rounds/:interview_round_id/prep/status
-
# Returns the current generation status (for polling)
-
def status
-
@artifact = @round.prep_artifacts.find_by(kind: :comprehensive)
-
-
respond_to do |format|
-
format.json do
-
render json: {
-
status: @artifact&.status || "not_started",
-
generated_at: @artifact&.generated_at,
-
has_content: @artifact&.has_content?
-
}
-
end
-
format.turbo_stream
-
end
-
end
-
-
private
-
-
def set_application
-
@application = Current.user.interview_applications.find(params[:interview_application_id])
-
rescue ActiveRecord::RecordNotFound
-
redirect_to interview_applications_path, alert: "Application not found"
-
end
-
-
def set_round
-
@round = @application.interview_rounds.find(params[:interview_round_id])
-
rescue ActiveRecord::RecordNotFound
-
redirect_to interview_application_path(@application), alert: "Interview round not found"
-
end
-
end
-
# frozen_string_literal: true
-
-
# Controller for managing interview rounds within an application
-
class InterviewRoundsController < ApplicationController
-
before_action :set_application
-
before_action :set_round, only: [ :show, :edit, :update, :destroy ]
-
-
# GET /interview_applications/:interview_application_id/interview_rounds
-
def index
-
@rounds = @application.interview_rounds.ordered
-
end
-
-
# GET /interview_applications/:interview_application_id/interview_rounds/:id
-
def show
-
end
-
-
# GET /interview_applications/:interview_application_id/interview_rounds/new
-
def new
-
@round = @application.interview_rounds.build(position: @application.interview_rounds.count + 1)
-
end
-
-
# GET /interview_applications/:interview_application_id/interview_rounds/:id/edit
-
def edit
-
end
-
-
# POST /interview_applications/:interview_application_id/interview_rounds
-
def create
-
@round = @application.interview_rounds.build(round_params)
-
-
if @round.save
-
respond_to do |format|
-
format.html { redirect_to interview_application_path(@application), notice: "Interview round added successfully!" }
-
format.turbo_stream { flash.now[:notice] = "Interview round added successfully!" }
-
end
-
else
-
render :new, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH/PUT /interview_applications/:interview_application_id/interview_rounds/:id
-
def update
-
previously_completed = @round.completed_at.present?
-
-
if @round.update(round_params)
-
maybe_unlock_insight_trial_after_second_completion(previously_completed: previously_completed)
-
respond_to do |format|
-
format.html { redirect_to interview_application_path(@application), notice: "Interview round updated successfully!" }
-
format.turbo_stream { flash.now[:notice] = "Interview round updated successfully!" }
-
end
-
else
-
render :edit, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /interview_applications/:interview_application_id/interview_rounds/:id
-
def destroy
-
@round.destroy
-
-
respond_to do |format|
-
format.html { redirect_to interview_application_path(@application), notice: "Interview round deleted successfully!", status: :see_other }
-
format.turbo_stream { flash.now[:notice] = "Interview round deleted successfully!" }
-
end
-
end
-
-
private
-
-
def set_application
-
@application = Current.user.interview_applications.find(params[:interview_application_id])
-
rescue ActiveRecord::RecordNotFound
-
redirect_to interview_applications_path, alert: "Application not found"
-
end
-
-
def set_round
-
@round = @application.interview_rounds.find(params[:id])
-
rescue ActiveRecord::RecordNotFound
-
redirect_to interview_application_path(@application), alert: "Interview round not found"
-
end
-
-
def round_params
-
params.expect(interview_round: [
-
:stage,
-
:stage_name,
-
:scheduled_at,
-
:completed_at,
-
:duration_minutes,
-
:interviewer_name,
-
:interviewer_role,
-
:notes,
-
:result,
-
:position,
-
:interview_round_type_id
-
])
-
end
-
-
# Unlocks the insight-triggered trial when the user completes their 2nd interview round.
-
#
-
# @param previously_completed [Boolean]
-
# @return [void]
-
def maybe_unlock_insight_trial_after_second_completion(previously_completed:)
-
return if previously_completed
-
return unless @round.completed_at.present?
-
-
user = Current.user
-
return if user.nil?
-
-
completed_count = user.interview_rounds.completed.count
-
return unless completed_count == 2
-
-
Billing::TrialUnlockService.new(
-
user: user,
-
trigger: :second_interview_completed,
-
metadata: { completed_count: completed_count, interview_round_id: @round.id }
-
).run
-
end
-
end
-
# frozen_string_literal: true
-
-
# Controller for managing job listings
-
class JobListingsController < ApplicationController
-
before_action :set_job_listing, only: [:show, :edit, :update, :destroy]
-
-
# GET /job_listings
-
def index
-
@job_listings = JobListing.includes(:company, :job_role).recent
-
-
if params[:company_id].present?
-
@job_listings = @job_listings.where(company_id: params[:company_id])
-
end
-
-
if params[:job_role_id].present?
-
@job_listings = @job_listings.where(job_role_id: params[:job_role_id])
-
end
-
-
if params[:remote_type].present?
-
@job_listings = @job_listings.where(remote_type: params[:remote_type])
-
end
-
-
if params[:status].present?
-
@job_listings = @job_listings.where(status: params[:status])
-
else
-
@job_listings = @job_listings.active
-
end
-
-
respond_to do |format|
-
format.html
-
format.json { render json: @job_listings }
-
end
-
end
-
-
# GET /job_listings/:id
-
def show
-
@applications = @job_listing.interview_applications.includes(:user).recent
-
end
-
-
# GET /job_listings/new
-
def new
-
@job_listing = JobListing.new
-
@companies = Company.alphabetical.limit(100)
-
@job_roles = JobRole.alphabetical.limit(100)
-
end
-
-
# GET /job_listings/:id/edit
-
def edit
-
@companies = Company.alphabetical.limit(100)
-
@job_roles = JobRole.alphabetical.limit(100)
-
end
-
-
# POST /job_listings
-
def create
-
@job_listing = JobListing.new(job_listing_params)
-
process_custom_sections(@job_listing)
-
-
if @job_listing.save
-
respond_to do |format|
-
format.html { redirect_to @job_listing, notice: "Job listing created successfully!" }
-
format.turbo_stream { flash.now[:notice] = "Job listing created successfully!" }
-
end
-
else
-
@companies = Company.alphabetical.limit(100)
-
@job_roles = JobRole.alphabetical.limit(100)
-
render :new, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH/PUT /job_listings/:id
-
def update
-
@job_listing.assign_attributes(job_listing_params)
-
process_custom_sections(@job_listing)
-
-
if @job_listing.save
-
respond_to do |format|
-
format.html { redirect_to @job_listing, notice: "Job listing updated successfully!" }
-
format.turbo_stream { flash.now[:notice] = "Job listing updated successfully!" }
-
end
-
else
-
@companies = Company.alphabetical.limit(100)
-
@job_roles = JobRole.alphabetical.limit(100)
-
render :edit, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /job_listings/:id
-
def destroy
-
@job_listing.destroy
-
-
respond_to do |format|
-
format.html { redirect_to job_listings_path, notice: "Job listing deleted successfully!", status: :see_other }
-
format.turbo_stream { flash.now[:notice] = "Job listing deleted successfully!" }
-
end
-
end
-
-
private
-
-
def set_job_listing
-
@job_listing = JobListing.find(params[:id])
-
rescue ActiveRecord::RecordNotFound
-
redirect_to job_listings_path, alert: "Job listing not found"
-
end
-
-
def job_listing_params
-
params.expect(job_listing: [
-
:company_id,
-
:job_role_id,
-
:title,
-
:url,
-
:source_id,
-
:job_board_id,
-
:description,
-
:requirements,
-
:responsibilities,
-
:salary_min,
-
:salary_max,
-
:salary_currency,
-
:equity_info,
-
:benefits,
-
:perks,
-
:location,
-
:remote_type,
-
:status,
-
custom_sections_keys: [],
-
custom_sections_values: [],
-
custom_sections: {},
-
scraped_data: {}
-
])
-
end
-
-
def process_custom_sections(job_listing)
-
return unless params[:job_listing]
-
-
keys = params[:job_listing][:custom_sections_keys]
-
values = params[:job_listing][:custom_sections_values]
-
-
if keys.present? && values.present?
-
custom_sections = {}
-
keys.each_with_index do |key, index|
-
next if key.blank?
-
custom_sections[key] = values[index] if values[index].present?
-
end
-
job_listing.custom_sections = custom_sections
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
# Controller for managing job roles
-
class JobRolesController < ApplicationController
-
# GET /job_roles
-
def index
-
@job_roles = JobRole.enabled.alphabetical
-
-
if params[:q].present?
-
@job_roles = @job_roles.where("title ILIKE ?", "%#{params[:q]}%")
-
end
-
-
if params[:category_id].present?
-
@job_roles = @job_roles.by_category(params[:category_id])
-
end
-
-
@job_roles = @job_roles.limit(50)
-
-
respond_to do |format|
-
format.html
-
format.json { render json: @job_roles }
-
end
-
end
-
-
# GET /job_roles/autocomplete
-
def autocomplete
-
query = params[:q].to_s.strip
-
-
@job_roles = if query.present?
-
JobRole.enabled.where("title ILIKE ?", "%#{query}%")
-
.alphabetical
-
.limit(10)
-
else
-
JobRole.enabled.alphabetical.limit(10)
-
end
-
-
render json: @job_roles.map { |jr| { id: jr.id, title: jr.title, category: jr.category_name } }
-
end
-
-
# POST /job_roles
-
def create
-
# Handle both form params and JSON params (for auto-create)
-
if request.format.json?
-
# Auto-create from autocomplete - only title is required
-
title = (params[:title] || params.dig(:job_role, :title))&.strip
-
return render json: { errors: [ "Title is required" ] }, status: :unprocessable_entity if title.blank?
-
-
# Find by case-insensitive title
-
@job_role = JobRole.where("LOWER(title) = ?", title.downcase).first
-
-
if @job_role.nil?
-
# Create new job role
-
@job_role = JobRole.new(title: title)
-
if @job_role.save
-
render json: { id: @job_role.id, title: @job_role.title, name: @job_role.title }, status: :created
-
else
-
render json: { errors: @job_role.errors.full_messages }, status: :unprocessable_entity
-
end
-
else
-
# If it exists but was disabled, re-enable it
-
@job_role.update!(disabled_at: nil) if @job_role.disabled?
-
# Job role already exists, return it
-
render json: { id: @job_role.id, title: @job_role.title, name: @job_role.title }, status: :ok
-
end
-
else
-
# Regular form submission
-
@job_role = JobRole.new(job_role_params)
-
-
if @job_role.save
-
respond_to do |format|
-
format.html { redirect_to job_roles_path, notice: "Job role created successfully!" }
-
format.turbo_stream { flash.now[:notice] = "Job role created successfully!" }
-
end
-
else
-
respond_to do |format|
-
format.html { render :new, status: :unprocessable_entity }
-
end
-
end
-
end
-
end
-
-
private
-
-
def job_role_params
-
params.expect(job_role: [ :title, :category, :description ])
-
end
-
end
-
# frozen_string_literal: true
-
-
# Controller for handling OAuth callbacks from external providers
-
class OauthCallbacksController < ApplicationController
-
# Allow unauthenticated access for sign-in/sign-up flow
-
allow_unauthenticated_access only: [ :create, :failure ]
-
# Skip CSRF for OAuth callbacks (they come from external providers)
-
skip_before_action :verify_authenticity_token, only: [ :create ]
-
-
# GET/POST /auth/:provider/callback
-
# Handle successful OAuth authentication
-
def create
-
auth = request.env["omniauth.auth"]
-
-
if auth.nil?
-
handle_missing_auth
-
return
-
end
-
-
begin
-
if authenticated?
-
# User is already signed in - connect OAuth account
-
handle_account_connection(auth)
-
else
-
# User is not signed in - sign in or sign up with OAuth
-
handle_authentication(auth)
-
end
-
rescue ActiveRecord::RecordInvalid => e
-
Rails.logger.error "OAuth error: #{e.message}"
-
handle_error(e.record.errors.full_messages.join(", "))
-
rescue StandardError => e
-
Rails.logger.error "OAuth error: #{e.class} - #{e.message}"
-
handle_error("An error occurred. Please try again.")
-
end
-
end
-
-
# GET /auth/failure
-
# Handle OAuth authentication failures
-
def failure
-
error_message = params[:message] || "unknown error"
-
error_strategy = params[:strategy] || "unknown"
-
-
Rails.logger.warn "OAuth failure for #{error_strategy}: #{error_message}"
-
-
redirect_path = authenticated? ? settings_path(tab: "integrations") : new_session_path
-
redirect_to redirect_path,
-
alert: "Authentication failed: #{error_message.humanize}. Please try again."
-
end
-
-
private
-
-
# Handles the case when auth data is missing
-
# @return [void]
-
def handle_missing_auth
-
redirect_path = authenticated? ? settings_path(tab: "integrations") : new_session_path
-
redirect_to redirect_path, alert: "Authentication failed. Please try again."
-
end
-
-
# Handles connecting an OAuth account to an existing logged-in user
-
# @param auth [OmniAuth::AuthHash] The OAuth authentication data
-
# @return [void]
-
def handle_account_connection(auth)
-
@connected_account = ConnectedAccount.from_oauth(Current.user, auth)
-
-
redirect_to settings_path(tab: "integrations"),
-
notice: "Successfully connected your #{provider_name} account!"
-
end
-
-
# Handles sign-in or sign-up via OAuth
-
# @param auth [OmniAuth::AuthHash] The OAuth authentication data
-
# @return [void]
-
def handle_authentication(auth)
-
user = OauthAuthenticationService.new(auth).run
-
start_new_session_for(user)
-
-
redirect_to after_authentication_url,
-
notice: "Welcome! Successfully signed in with #{provider_name}."
-
end
-
-
# Handles errors during OAuth flow
-
# @param message [String] The error message
-
# @return [void]
-
def handle_error(message)
-
redirect_path = authenticated? ? settings_path(tab: "integrations") : new_session_path
-
redirect_to redirect_path, alert: message
-
end
-
-
# Returns a human-readable name for the provider
-
# @return [String]
-
def provider_name
-
case params[:provider]
-
when "google_oauth2"
-
"Google"
-
else
-
params[:provider].to_s.titleize
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# Controller for managing job opportunities from recruiter outreach
-
# Presents opportunities in a stacked-cards UI with Apply/Ignore actions
-
class OpportunitiesController < ApplicationController
-
before_action :set_opportunity, only: [ :show, :apply, :ignore, :restore, :update_url ]
-
-
# GET /opportunities
-
#
-
# Main opportunities view with stacked cards layout
-
def index
-
load_opportunity_stack(selected_id: params[:opportunity_id].presence&.to_i)
-
-
respond_to do |format|
-
format.html
-
format.turbo_stream do
-
render turbo_stream: [
-
turbo_stream.update("opportunities_stack", partial: "opportunities/stack"),
-
turbo_stream.update("opportunities_count", html: opportunities_count_badge)
-
]
-
end
-
end
-
end
-
-
# GET /opportunities/:id
-
#
-
# Show full opportunity details
-
def show
-
respond_to do |format|
-
format.html
-
format.turbo_stream do
-
render turbo_stream: turbo_stream.update(
-
"opportunity_detail",
-
partial: "opportunities/card",
-
locals: { opportunity: @opportunity, show_actions: true }
-
)
-
end
-
end
-
end
-
-
# POST /opportunities/:id/apply
-
#
-
# Creates an InterviewApplication from the opportunity
-
def apply
-
service = Opportunities::CreateApplicationService.new(@opportunity, Current.user)
-
result = service.call
-
-
respond_to do |format|
-
if result[:success]
-
format.html do
-
redirect_to result[:application],
-
notice: "Application created for #{result[:company].name}!"
-
end
-
format.turbo_stream do
-
load_opportunity_stack
-
-
render turbo_stream: [
-
turbo_stream.update("opportunities_stack", partial: "opportunities/stack"),
-
turbo_stream.update("opportunities_count", html: opportunities_count_badge)
-
]
-
end
-
format.json { render json: { success: true, application_id: result[:application].id } }
-
else
-
format.html do
-
redirect_to opportunities_path,
-
alert: result[:error] || "Could not create application."
-
end
-
format.turbo_stream do
-
render turbo_stream: turbo_stream.update(
-
"flash",
-
partial: "shared/flash",
-
locals: { flash: { alert: result[:error] } }
-
)
-
end
-
format.json { render json: { success: false, error: result[:error] }, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# POST /opportunities/:id/ignore
-
#
-
# Marks the opportunity as ignored and shows the next one
-
def ignore
-
if @opportunity.archive_as_ignored!
-
respond_to do |format|
-
format.html { redirect_to opportunities_path, notice: "Opportunity ignored." }
-
format.turbo_stream do
-
load_opportunity_stack
-
-
render turbo_stream: [
-
turbo_stream.update("opportunities_stack", partial: "opportunities/stack"),
-
turbo_stream.update("opportunities_count", html: opportunities_count_badge)
-
]
-
end
-
format.json { render json: { success: true } }
-
end
-
else
-
respond_to do |format|
-
format.html { redirect_to opportunities_path, alert: "Could not ignore opportunity." }
-
format.turbo_stream do
-
render turbo_stream: turbo_stream.update(
-
"flash",
-
partial: "shared/flash",
-
locals: { flash: { alert: "Could not ignore opportunity." } }
-
)
-
end
-
format.json { render json: { success: false }, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# POST /opportunities/:id/restore
-
#
-
# Restores an archived opportunity back to the stack
-
def restore
-
if @opportunity.reconsider!
-
respond_to do |format|
-
format.html { redirect_to opportunities_path, notice: "Opportunity restored." }
-
format.turbo_stream do
-
load_opportunity_stack(selected_id: @opportunity.id)
-
-
render turbo_stream: [
-
turbo_stream.update("opportunities_stack", partial: "opportunities/stack"),
-
turbo_stream.update("opportunities_count", html: opportunities_count_badge)
-
]
-
end
-
format.json { render json: { success: true } }
-
end
-
else
-
respond_to do |format|
-
format.html { redirect_to opportunities_path, alert: "Could not restore opportunity." }
-
format.turbo_stream do
-
render turbo_stream: turbo_stream.update(
-
"flash",
-
partial: "shared/flash",
-
locals: { flash: { alert: "Could not restore opportunity." } }
-
)
-
end
-
format.json { render json: { success: false }, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# PATCH /opportunities/:id/update_url
-
#
-
# Updates the job URL for manual entry
-
def update_url
-
if @opportunity.update(job_url: params[:job_url])
-
# Optionally trigger extraction if URL changed
-
if @opportunity.job_url.present? && @opportunity.saved_change_to_job_url?
-
ProcessOpportunityEmailJob.perform_later(@opportunity.id)
-
end
-
-
respond_to do |format|
-
format.html { redirect_to opportunity_path(@opportunity), notice: "URL updated." }
-
format.turbo_stream do
-
render turbo_stream: turbo_stream.update(
-
"opportunity_card_#{@opportunity.id}",
-
partial: "opportunities/card",
-
locals: { opportunity: @opportunity, show_actions: true }
-
)
-
end
-
format.json { render json: { success: true, job_url: @opportunity.job_url } }
-
end
-
else
-
respond_to do |format|
-
format.html { redirect_to opportunity_path(@opportunity), alert: "Could not update URL." }
-
format.turbo_stream do
-
render turbo_stream: turbo_stream.update(
-
"flash",
-
partial: "shared/flash",
-
locals: { flash: { alert: "Could not update URL." } }
-
)
-
end
-
format.json { render json: { success: false }, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
private
-
-
# Sets the opportunity for member actions
-
#
-
# @return [Opportunity]
-
def set_opportunity
-
@opportunity = current_user_opportunities.find(params[:id])
-
rescue ActiveRecord::RecordNotFound
-
respond_to do |format|
-
format.html { redirect_to opportunities_path, alert: "Opportunity not found." }
-
format.turbo_stream do
-
render turbo_stream: turbo_stream.update(
-
"flash",
-
partial: "shared/flash",
-
locals: { flash: { alert: "Opportunity not found." } }
-
)
-
end
-
format.json { render json: { error: "Not found" }, status: :not_found }
-
end
-
end
-
-
# Returns the current user's opportunities
-
#
-
# @return [ActiveRecord::Relation]
-
def current_user_opportunities
-
Current.user.opportunities
-
end
-
-
# Returns HTML for the opportunities count badge
-
#
-
# @return [String]
-
def opportunities_count_badge
-
count = actionable_unsaved_opportunities.count
-
return "" if count == 0
-
-
helpers.content_tag(:span, count,
-
class: "ml-auto inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-400"
-
)
-
end
-
-
def actionable_unsaved_opportunities
-
current_user_opportunities
-
.actionable
-
.left_outer_joins(:saved_job)
-
.where("saved_jobs.id IS NULL OR saved_jobs.status = 'archived'")
-
end
-
-
def load_opportunity_stack(selected_id: nil)
-
@opportunities = actionable_unsaved_opportunities
-
.includes(:synced_email)
-
.recent
-
-
@current_opportunity = if selected_id
-
@opportunities.detect { |o| o.id == selected_id } || @opportunities.first
-
else
-
@opportunities.first
-
end
-
-
if @current_opportunity
-
ids = @opportunities.map(&:id)
-
idx = ids.index(@current_opportunity.id) || 0
-
-
@current_position = idx + 1
-
@total_count = ids.length
-
@prev_opportunity_id = idx.positive? ? ids[idx - 1] : nil
-
@next_opportunity_id = (idx + 1) < ids.length ? ids[idx + 1] : nil
-
@remaining_count = ids.length - @current_position
-
else
-
@current_position = 0
-
@total_count = 0
-
@prev_opportunity_id = nil
-
@next_opportunity_id = nil
-
@remaining_count = nil
-
end
-
end
-
-
# Strong parameters for opportunity updates
-
#
-
# @return [ActionController::Parameters]
-
def opportunity_params
-
params.expect(opportunity: [ :job_url ])
-
end
-
end
-
class PasswordsController < ApplicationController
-
allow_unauthenticated_access
-
before_action :set_user_by_token, only: %i[ edit update ]
-
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
-
layout "authentication"
-
-
def new
-
end
-
-
def create
-
if user = User.find_by(email_address: params[:email_address])
-
PasswordsMailer.reset(user).deliver_later
-
end
-
-
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
-
end
-
-
def edit
-
end
-
-
def update
-
if @user.update(params.permit(:password, :password_confirmation))
-
@user.sessions.destroy_all
-
redirect_to new_session_path, notice: "Password has been reset."
-
else
-
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
-
end
-
end
-
-
private
-
def set_user_by_token
-
@user = User.find_by_password_reset_token!(params[:token])
-
rescue ActiveSupport::MessageVerifier::InvalidSignature
-
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
-
end
-
end
-
# frozen_string_literal: true
-
-
# Controller for user profile and insights
-
class ProfilesController < ApplicationController
-
# GET /profile
-
def show
-
@user = Current.user
-
@insights = ProfileInsightsService.new(@user).generate_insights
-
@companies = Company.alphabetical.limit(100)
-
@job_roles = JobRole.alphabetical.limit(100)
-
end
-
-
# GET /profile/edit
-
def edit
-
@user = Current.user
-
@companies = Company.alphabetical.limit(100)
-
@job_roles = JobRole.alphabetical.limit(100)
-
end
-
-
# PATCH/PUT /profile
-
def update
-
@user = Current.user
-
-
if @user.update(profile_params)
-
redirect_to profile_path, notice: "Profile updated successfully!"
-
else
-
@companies = Company.alphabetical.limit(100)
-
@job_roles = JobRole.alphabetical.limit(100)
-
render :edit, status: :unprocessable_entity
-
end
-
end
-
-
private
-
-
def profile_params
-
params.expect(user: [
-
:name,
-
:bio,
-
:current_job_role_id,
-
:current_company_id,
-
:years_of_experience,
-
:linkedin_url,
-
:github_url,
-
:gitlab_url,
-
:twitter_url,
-
:portfolio_url,
-
target_job_role_ids: [],
-
target_company_ids: []
-
])
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Public
-
# Base controller for all public-facing pages
-
#
-
# Provides unauthenticated access for marketing pages like homepage,
-
# contact, pricing, etc. All public controllers should inherit from this class.
-
#
-
# @example
-
# class Public::HomeController < Public::BaseController
-
# def index
-
# # Public homepage action
-
# end
-
# end
-
class BaseController < ApplicationController
-
allow_unauthenticated_access
-
-
layout "public"
-
-
before_action :set_default_meta_tags
-
-
private
-
-
# Sets baseline SEO meta tags for public pages.
-
#
-
# Individual controllers/actions can override via `set_meta_tags`.
-
# @return [void]
-
def set_default_meta_tags
-
set_meta_tags(
-
site: "Gleania",
-
reverse: true,
-
separator: "—",
-
description: "Gleania helps you track interviews, gather feedback, and grow your skills with AI-powered reflection.",
-
canonical: request.original_url,
-
og: {
-
site_name: "Gleania",
-
type: "website",
-
url: request.original_url
-
},
-
twitter: {
-
card: "summary_large_image"
-
}
-
)
-
end
-
-
# Redirects authenticated users to dashboard
-
#
-
# Can be used in before_action to redirect logged-in users
-
# away from public pages like login/register.
-
# @return [void]
-
def redirect_authenticated_users
-
redirect_to interview_applications_path if authenticated?
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Public
-
# Controller for the public blog.
-
class BlogController < BaseController
-
# GET /blog
-
def index
-
@posts = BlogPost.published_publicly.recent_first
-
@tag_cloud = ActsAsTaggableOn::Tag
-
.joins(:taggings)
-
.where(taggings: { taggable_type: "BlogPost", context: "tags" })
-
.group("tags.id")
-
.select("tags.*, COUNT(taggings.id) AS usage_count")
-
.order("usage_count DESC, tags.name ASC")
-
.limit(30)
-
-
set_meta_tags(
-
title: "Blog",
-
description: "Product thinking, interview strategy, and job-search workflows from Gleania.",
-
canonical: blog_index_url,
-
og: {
-
type: "website",
-
url: blog_index_url,
-
title: "The Gleania Blog",
-
description: "Product thinking, interview strategy, and job-search workflows from Gleania.",
-
site_name: "Gleania"
-
},
-
twitter: {
-
card: "summary",
-
site: "@GleaniaApp",
-
title: "The Gleania Blog",
-
description: "Product thinking, interview strategy, and job-search workflows from Gleania."
-
}
-
)
-
end
-
-
# GET /blog/:slug
-
def show
-
@post = BlogPost.friendly.find(params[:slug])
-
raise ActiveRecord::RecordNotFound unless @post.publicly_visible?
-
-
rendered = MarkdownRenderer.new(@post.body).render
-
@content_html = rendered[:html]
-
@toc = rendered[:toc]
-
@reading_time_minutes = rendered[:reading_time_minutes]
-
-
og_image =
-
if @post.cover_image.attached?
-
if Rails.env.production?
-
@post.cover_image.url
-
else
-
url_for(@post.cover_image_variant(size: :og))
-
end
-
end
-
-
description = @post.excerpt.presence || "Read #{@post.title} on the Gleania blog."
-
-
set_meta_tags(
-
title: @post.title,
-
description: description,
-
canonical: blog_url(@post.slug),
-
# Open Graph (used by LinkedIn, Facebook, etc.)
-
og: {
-
type: "article",
-
url: blog_url(@post.slug),
-
title: @post.title,
-
description: description,
-
image: og_image,
-
site_name: "Gleania"
-
},
-
# Article-specific meta (LinkedIn reads these)
-
article: {
-
published_time: @post.published_at&.iso8601,
-
modified_time: @post.updated_at&.iso8601,
-
author: @post.author_name.presence || "Gleania Team",
-
section: "Interview Tips",
-
tag: @post.tag_list.to_a
-
},
-
# Twitter Card
-
twitter: {
-
card: "summary_large_image",
-
site: "@GleaniaApp",
-
creator: "@GleaniaApp",
-
title: @post.title,
-
description: description,
-
image: og_image
-
}
-
)
-
rescue ActiveRecord::RecordNotFound
-
redirect_to blog_index_path, alert: "Post not found."
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Public
-
# Controller for public tag pages (blog posts filtered by a tag).
-
class BlogTagsController < BaseController
-
# GET /blog/tags/:tag
-
def show
-
tag_param = params[:tag].to_s
-
# Find tag by slug (parameterized) or exact name
-
@tag_record = ActsAsTaggableOn::Tag.find_by("LOWER(name) = ?", tag_param.tr("-", " ").downcase)
-
@tag_record ||= ActsAsTaggableOn::Tag.find_by(name: tag_param)
-
-
if @tag_record.nil?
-
redirect_to blog_index_path, alert: "Tag not found."
-
return
-
end
-
-
@tag = @tag_record.name
-
@posts = BlogPost.published_publicly.tagged_with(@tag).recent_first
-
-
if @posts.blank?
-
redirect_to blog_index_path, alert: "No posts found for this tag."
-
return
-
end
-
-
# Redirect to canonical slug URL if accessed with spaces or wrong case
-
canonical_slug = @tag.parameterize
-
if tag_param != canonical_slug
-
redirect_to blog_tag_path(canonical_slug), status: :moved_permanently
-
return
-
end
-
-
set_meta_tags(
-
title: "Tag: #{@tag}",
-
description: "Posts tagged with #{@tag} on the Gleania blog.",
-
canonical: blog_tag_url(canonical_slug),
-
og: { type: "website", url: blog_tag_url(canonical_slug) }
-
)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Public
-
# Controller for the contact page
-
#
-
# Handles the public contact form and inquiries.
-
class ContactsController < BaseController
-
# GET /contact
-
#
-
# Renders the contact page form.
-
def show
-
@support_ticket = SupportTicket.new
-
end
-
-
# POST /contact
-
#
-
# Handles contact form submission and creates a support ticket.
-
def create
-
# Verify Turnstile token
-
unless verify_turnstile_token
-
@support_ticket = SupportTicket.new(support_ticket_params)
-
@support_ticket.errors.add(:base, "Verification failed. Please try again.")
-
render :show, status: :unprocessable_entity
-
return
-
end
-
-
@support_ticket = SupportTicket.new(support_ticket_params)
-
@support_ticket.user = Current.user if authenticated?
-
-
if @support_ticket.save
-
redirect_to contact_path, notice: "Thank you for your message. We'll be in touch soon!"
-
else
-
render :show, status: :unprocessable_entity
-
end
-
end
-
-
private
-
-
# Strong parameters for support ticket
-
#
-
# @return [ActionController::Parameters]
-
def support_ticket_params
-
params.expect(support_ticket: [ :name, :email, :subject, :message ])
-
end
-
-
# Verifies Turnstile token if configured
-
#
-
# @return [Boolean]
-
def verify_turnstile_token
-
# Skip verification in development/test environments
-
return true if Rails.env.development? || Rails.env.test?
-
# Skip if Turnstile is not fully configured
-
return true unless turnstile_configured?
-
-
token = params["cf-turnstile-response"]
-
CloudflareTurnstileService.verify(token, request.remote_ip)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Public
-
# Controller for the public homepage
-
#
-
# Handles the main marketing landing page for Gleania.
-
class HomeController < BaseController
-
# GET /
-
#
-
# Renders the public homepage with all marketing sections.
-
# Authenticated users can optionally be redirected to their dashboard.
-
def index
-
# Optionally redirect authenticated users to dashboard
-
# Uncomment the next line if you want to auto-redirect logged-in users
-
# redirect_to interview_applications_path if authenticated?
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Public
-
# Controller for public legal pages (Privacy, Terms, Cookies).
-
#
-
# These pages are required for OAuth verification and are accessible without authentication.
-
class LegalController < BaseController
-
# GET /privacy
-
def privacy
-
set_meta_tags(title: "Privacy Policy", canonical: privacy_url)
-
end
-
-
# GET /terms
-
def terms
-
set_meta_tags(title: "Terms of Service", canonical: terms_url)
-
end
-
-
# GET /cookies
-
#
-
# Named `cookies_policy` to avoid colliding with ActionController's `cookies` accessor.
-
def cookies_policy
-
set_meta_tags(title: "Cookie Policy", canonical: cookies_url)
-
render :cookies
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Public
-
# Newsletter capture endpoints (public).
-
class NewsletterSubscriptionsController < BaseController
-
# POST /newsletter/subscribe
-
def create
-
email = params[:email].to_s.strip
-
if email.blank?
-
redirect_back fallback_location: blog_index_path, alert: "Please enter an email."
-
return
-
end
-
-
subscriber = NewsletterSubscriber.find_or_initialize_by(email: email)
-
subscriber.save!
-
-
Mailkick::Subscription.find_or_create_by!(subscriber: subscriber, list: "newsletter")
-
-
redirect_back fallback_location: blog_index_path, notice: "Thanks! You're subscribed."
-
rescue ActiveRecord::RecordInvalid
-
redirect_back fallback_location: blog_index_path, alert: "Please enter a valid email."
-
end
-
-
# GET /newsletter/unsubscribe/:signed_id
-
def destroy
-
subscriber = NewsletterSubscriber.find_signed!(params[:signed_id])
-
Mailkick::Subscription.where(subscriber: subscriber, list: "newsletter").delete_all
-
redirect_to blog_index_path, notice: "You have been unsubscribed."
-
rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
-
redirect_to blog_index_path, alert: "Invalid unsubscribe link."
-
end
-
end
-
end
-
-
-
# frozen_string_literal: true
-
-
module Public
-
# Public pricing page.
-
class PricingController < BaseController
-
# GET /pricing
-
def show
-
@plans = Billing::Catalog.published_plans
-
end
-
end
-
end
-
-
-
# frozen_string_literal: true
-
-
module Public
-
# Controller for generating a basic sitemap.xml.
-
class SitemapsController < BaseController
-
# GET /sitemap.xml
-
def show
-
@posts = BlogPost.published_publicly.select(:slug, :updated_at)
-
@tags = ActsAsTaggableOn::Tag.joins(:taggings)
-
.where(taggings: { taggable_type: "BlogPost" })
-
.distinct
-
.pluck(:name)
-
respond_to do |format|
-
format.xml
-
end
-
end
-
end
-
end
-
class RegistrationsController < ApplicationController
-
allow_unauthenticated_access
-
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_registration_path, alert: "Try again later." }
-
layout "authentication"
-
-
# GET /registrations/new
-
# Show registration form
-
def new
-
redirect_to root_path, alert: "Sign up is disabled." unless Setting.user_sign_up_enabled?
-
-
@user = User.new
-
end
-
-
# POST /registrations
-
# Create new user account
-
def create
-
# Verify Turnstile token
-
unless verify_turnstile_token
-
@user = User.new(registration_params)
-
@user.errors.add(:base, "Verification failed. Please try again.")
-
render :new, status: :unprocessable_entity
-
return
-
end
-
-
@user = User.new(registration_params)
-
-
if @user.save
-
# Send verification email
-
UserMailer.verify_email(@user).deliver_later
-
-
redirect_to new_session_path, notice: "Account created! Please check your email to verify your account."
-
else
-
render :new, status: :unprocessable_entity
-
end
-
end
-
-
private
-
def registration_params
-
params.expect(user: [ :email_address, :password, :password_confirmation, :name, :terms_accepted, :marketing_opt_in ])
-
end
-
-
# Verifies Turnstile token if configured
-
#
-
# @return [Boolean]
-
def verify_turnstile_token
-
return true if Rails.env.development? || Rails.env.test?
-
return true unless turnstile_configured?
-
-
token = params["cf-turnstile-response"]
-
CloudflareTurnstileService.verify(token, request.remote_ip)
-
end
-
end
-
# frozen_string_literal: true
-
-
# Controller for managing individual skills extracted from resumes
-
#
-
# Handles user adjustments to skill levels and skill management (delete, merge)
-
class ResumeSkillsController < ApplicationController
-
before_action :set_user_resume
-
before_action :set_resume_skill, only: [ :update, :destroy ]
-
-
# PATCH /resumes/:user_resume_id/skills/:id
-
#
-
# Update user-confirmed proficiency level for a skill
-
def update
-
respond_to do |format|
-
if @resume_skill.update(resume_skill_params)
-
format.html { redirect_to user_resume_path(@user_resume), notice: "Skill updated." }
-
format.turbo_stream do
-
render turbo_stream: turbo_stream.replace(
-
"skill_row_#{@resume_skill.id}",
-
partial: "resume_skills/skill_row",
-
locals: { resume_skill: @resume_skill }
-
)
-
end
-
format.json { render json: { success: true, skill: skill_json(@resume_skill) } }
-
else
-
format.html { redirect_to user_resume_path(@user_resume), alert: "Could not update skill." }
-
format.turbo_stream do
-
render turbo_stream: turbo_stream.update(
-
"flash",
-
partial: "shared/flash",
-
locals: { flash: { alert: @resume_skill.errors.full_messages.join(", ") } }
-
)
-
end
-
format.json { render json: { success: false, errors: @resume_skill.errors.full_messages }, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# DELETE /resumes/:user_resume_id/skills/:id
-
#
-
# Remove an irrelevant skill from the resume
-
def destroy
-
skill_name = @resume_skill.skill_name
-
@resume_skill.destroy!
-
-
respond_to do |format|
-
format.html { redirect_to user_resume_path(@user_resume), notice: "#{skill_name} removed." }
-
format.turbo_stream do
-
render turbo_stream: [
-
turbo_stream.remove("skill_row_#{params[:id]}"),
-
turbo_stream.update("flash", partial: "shared/flash", locals: { flash: { notice: "#{skill_name} removed." } })
-
]
-
end
-
format.json { render json: { success: true } }
-
end
-
end
-
-
# POST /resumes/:user_resume_id/skills/merge
-
#
-
# Merge duplicate skills (e.g., "PostgreSQL" and "Postgres")
-
def merge
-
source_skill_id = params[:source_skill_id]
-
target_skill_id = params[:target_skill_id]
-
-
source_skill = SkillTag.find_by(id: source_skill_id)
-
target_skill = SkillTag.find_by(id: target_skill_id)
-
-
unless source_skill && target_skill
-
respond_to do |format|
-
format.html { redirect_to user_resume_path(@user_resume), alert: "Skills not found." }
-
format.json { render json: { success: false, error: "Skills not found" }, status: :not_found }
-
end
-
return
-
end
-
-
if SkillTag.merge_skills(source_skill, target_skill)
-
# Re-aggregate skills for the user
-
Resumes::SkillAggregationService.new(Current.user).aggregate_skill(target_skill)
-
-
respond_to do |format|
-
format.html { redirect_to user_resume_path(@user_resume), notice: "Skills merged successfully." }
-
format.turbo_stream do
-
@resume_skills = @user_resume.resume_skills.includes(:skill_tag).order(Arel.sql("COALESCE(user_level, model_level) DESC"))
-
render turbo_stream: [
-
turbo_stream.update("skills_list", partial: "resume_skills/skills_list", locals: { resume_skills: @resume_skills }),
-
turbo_stream.update("flash", partial: "shared/flash", locals: { flash: { notice: "Skills merged successfully." } })
-
]
-
end
-
format.json { render json: { success: true } }
-
end
-
else
-
respond_to do |format|
-
format.html { redirect_to user_resume_path(@user_resume), alert: "Could not merge skills." }
-
format.json { render json: { success: false, error: "Merge failed" }, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# POST /resumes/:user_resume_id/skills/bulk_update
-
#
-
# Update multiple skills at once (after review)
-
def bulk_update
-
skills_data = params[:skills] || []
-
updated_count = 0
-
-
skills_data.each do |skill_data|
-
resume_skill = @user_resume.resume_skills.find_by(id: skill_data[:id])
-
next unless resume_skill
-
-
if resume_skill.update(user_level: skill_data[:user_level])
-
updated_count += 1
-
end
-
end
-
-
respond_to do |format|
-
format.html { redirect_to user_resume_path(@user_resume), notice: "#{updated_count} skills updated." }
-
format.turbo_stream do
-
@resume_skills = @user_resume.resume_skills.includes(:skill_tag).order(Arel.sql("COALESCE(user_level, model_level) DESC"))
-
render turbo_stream: [
-
turbo_stream.update("skills_list", partial: "resume_skills/skills_list", locals: { resume_skills: @resume_skills }),
-
turbo_stream.update("flash", partial: "shared/flash", locals: { flash: { notice: "#{updated_count} skills updated." } })
-
]
-
end
-
format.json { render json: { success: true, updated_count: updated_count } }
-
end
-
end
-
-
private
-
-
# Sets the parent resume
-
#
-
# @return [UserResume]
-
def set_user_resume
-
@user_resume = Current.user.user_resumes.find(params[:user_resume_id])
-
rescue ActiveRecord::RecordNotFound
-
respond_to do |format|
-
format.html { redirect_to user_resumes_path, alert: "Resume not found." }
-
format.json { render json: { error: "Not found" }, status: :not_found }
-
end
-
end
-
-
# Sets the resume skill for member actions
-
#
-
# @return [ResumeSkill]
-
def set_resume_skill
-
@resume_skill = @user_resume.resume_skills.find(params[:id])
-
rescue ActiveRecord::RecordNotFound
-
respond_to do |format|
-
format.html { redirect_to user_resume_path(@user_resume), alert: "Skill not found." }
-
format.json { render json: { error: "Not found" }, status: :not_found }
-
end
-
end
-
-
# Strong parameters for skill updates
-
#
-
# @return [ActionController::Parameters]
-
def resume_skill_params
-
params.expect(resume_skill: [ :user_level ])
-
end
-
-
# Builds JSON representation of a skill
-
#
-
# @param resume_skill [ResumeSkill]
-
# @return [Hash]
-
def skill_json(resume_skill)
-
{
-
id: resume_skill.id,
-
skill_name: resume_skill.skill_name,
-
model_level: resume_skill.model_level,
-
user_level: resume_skill.user_level,
-
effective_level: resume_skill.effective_level,
-
confidence_score: resume_skill.confidence_score,
-
category: resume_skill.category
-
}
-
end
-
end
-
# frozen_string_literal: true
-
-
# Controller for managing Saved Jobs (bookmarked job leads).
-
#
-
# Supports saving from an Opportunity or from a pasted URL, and converting a saved job
-
# into an InterviewApplication using existing services.
-
class SavedJobsController < ApplicationController
-
before_action :set_saved_job, only: [ :destroy, :convert ]
-
before_action :set_saved_job_any_status, only: [ :restore ]
-
-
# GET /saved_jobs
-
def index
-
@saved_jobs = Current.user.saved_jobs.active
-
.includes(:opportunity, :fit_assessment)
-
.recent
-
end
-
-
# POST /saved_jobs
-
def create
-
@saved_job = build_saved_job_from_params
-
-
if @saved_job.save
-
redirect_back fallback_location: saved_jobs_path, notice: "Saved."
-
else
-
redirect_back fallback_location: saved_jobs_path, alert: @saved_job.errors.full_messages.to_sentence
-
end
-
end
-
-
# DELETE /saved_jobs/:id
-
def destroy
-
if @saved_job.archive_removed!
-
redirect_back fallback_location: saved_jobs_path, notice: "Removed."
-
else
-
redirect_back fallback_location: saved_jobs_path, alert: "Could not remove."
-
end
-
end
-
-
# POST /saved_jobs/:id/restore
-
def restore
-
if @saved_job.restore!
-
redirect_back fallback_location: saved_jobs_path, notice: "Restored."
-
else
-
redirect_back fallback_location: saved_jobs_path, alert: "Could not restore."
-
end
-
end
-
-
# POST /saved_jobs/:id/convert
-
def convert
-
result = convert_saved_job(@saved_job)
-
-
if result[:success]
-
@saved_job.update!(converted_at: Time.current) if @saved_job.converted_at.blank?
-
redirect_to result[:application], notice: "Application created."
-
else
-
redirect_back fallback_location: saved_jobs_path, alert: result[:error] || "Could not convert saved job."
-
end
-
end
-
-
private
-
-
def set_saved_job
-
@saved_job = Current.user.saved_jobs.active.find(params[:id])
-
end
-
-
def set_saved_job_any_status
-
@saved_job = Current.user.saved_jobs.find(params[:id])
-
end
-
-
def saved_job_params
-
params.expect(saved_job: [ :url, :notes, :opportunity_id ])
-
end
-
-
def build_saved_job_from_params
-
attrs = saved_job_params
-
-
if attrs[:opportunity_id].present?
-
opportunity = Current.user.opportunities.find(attrs[:opportunity_id])
-
Current.user.saved_jobs.new(
-
opportunity: opportunity,
-
url: nil,
-
company_name: opportunity.company_name,
-
job_role_title: opportunity.job_role_title,
-
title: opportunity.job_role_title,
-
notes: attrs[:notes]
-
)
-
else
-
Current.user.saved_jobs.new(
-
url: attrs[:url]&.strip,
-
notes: attrs[:notes]
-
)
-
end
-
end
-
-
def convert_saved_job(saved_job)
-
if saved_job.opportunity.present?
-
Opportunities::CreateApplicationService.new(saved_job.opportunity, Current.user).call
-
else
-
url = saved_job.effective_url
-
return { success: false, error: "URL is missing" } if url.blank?
-
-
QuickApplyFromUrlService.new(url, Current.user).call
-
end
-
end
-
end
-
class SessionsController < ApplicationController
-
allow_unauthenticated_access only: %i[ new create ]
-
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
-
layout "authentication"
-
-
def new
-
redirect_to root_path, alert: "Sign in is disabled." unless Setting.user_login_enabled?
-
end
-
-
def create
-
unless Setting.username_password_login_enabled?
-
redirect_to new_session_path, alert: "Username and password login is disabled."
-
return
-
end
-
-
if user = User.authenticate_by(params.permit(:email_address, :password))
-
if user.email_verified?
-
start_new_session_for user
-
redirect_to after_authentication_url
-
else
-
redirect_to new_email_verification_path(email_address: user.email_address),
-
alert: "Please verify your email first. You can request a new verification link below."
-
end
-
else
-
redirect_to new_session_path, alert: "Try another email address or password."
-
end
-
end
-
-
def destroy
-
terminate_session
-
redirect_to new_session_path, status: :see_other
-
end
-
end
-
# frozen_string_literal: true
-
-
# Controller for user settings management
-
# Handles profile, preferences, notifications, AI settings, integrations, privacy, security,
-
# work experience, and targets
-
class SettingsController < ApplicationController
-
before_action :set_user
-
before_action :set_preference
-
before_action :load_profile_data, only: [ :show, :update_profile ]
-
before_action :set_work_experience, only: [ :update_work_experience, :destroy_work_experience ]
-
-
# GET /settings
-
def show
-
@active_tab = params[:tab] || "profile"
-
@sessions = @user.sessions.order(created_at: :desc) if @active_tab == "security"
-
@connected_accounts = @user.connected_accounts if @active_tab == "integrations" && @user.respond_to?(:connected_accounts)
-
load_subscription_data if @active_tab == "subscription"
-
load_billing_data if @active_tab == "billing"
-
load_work_experience_data if @active_tab == "work_experience"
-
load_targets_data if @active_tab == "targets"
-
end
-
-
# PATCH /settings/profile
-
def update_profile
-
if @user.update(profile_params)
-
respond_to_update("profile", "Profile updated successfully.")
-
else
-
respond_to_error("profile")
-
end
-
end
-
-
# PATCH /settings/general
-
def update_general
-
if @preference.update(general_params)
-
respond_to_update("general", "General settings updated successfully.")
-
else
-
respond_to_error("general")
-
end
-
end
-
-
# PATCH /settings/notifications
-
def update_notifications
-
if @preference.update(notification_params)
-
respond_to_update("notifications", "Notification settings updated successfully.")
-
else
-
respond_to_error("notifications")
-
end
-
end
-
-
# PATCH /settings/ai_preferences
-
def update_ai_preferences
-
if @preference.update(ai_preference_params)
-
respond_to_update("ai_preferences", "AI preferences updated successfully.")
-
else
-
respond_to_error("ai_preferences")
-
end
-
end
-
-
# PATCH /settings/privacy
-
def update_privacy
-
if @preference.update(privacy_params)
-
respond_to_update("privacy", "Privacy settings updated successfully.")
-
else
-
respond_to_error("privacy")
-
end
-
end
-
-
# PATCH /settings/security
-
def update_security
-
if @user.update(security_params)
-
redirect_to settings_path(tab: "security"), notice: "Security settings updated successfully."
-
else
-
@active_tab = "security"
-
@sessions = @user.sessions.order(created_at: :desc)
-
render :show, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /settings/sessions/:id
-
def destroy_session
-
session_to_destroy = @user.sessions.find_by(id: params[:session_id])
-
-
if session_to_destroy
-
is_current = session_to_destroy.id == Current.session&.id
-
session_to_destroy.destroy
-
-
if is_current
-
redirect_to new_session_path, notice: "You have been signed out.", status: :see_other
-
else
-
redirect_to settings_path(tab: "security"), notice: "Session revoked successfully.", status: :see_other
-
end
-
else
-
redirect_to settings_path(tab: "security"), alert: "Session not found.", status: :see_other
-
end
-
end
-
-
# DELETE /settings/sessions
-
def destroy_all_sessions
-
@user.sessions.where.not(id: Current.session&.id).destroy_all
-
redirect_to settings_path(tab: "security"), notice: "All other sessions have been signed out.", status: :see_other
-
end
-
-
# DELETE /settings/disconnect/:provider
-
def disconnect_provider
-
return redirect_to settings_path(tab: "integrations"), alert: "Provider not specified." unless params[:provider].present?
-
-
# If account_id is provided, disconnect that specific account
-
# Otherwise, disconnect the first account found (for backward compatibility)
-
if params[:account_id].present?
-
account = @user.connected_accounts.find_by(id: params[:account_id], provider: params[:provider])
-
else
-
account = @user.connected_accounts.find_by(provider: params[:provider])
-
end
-
-
if account&.destroy
-
redirect_to settings_path(tab: "integrations"), notice: "#{params[:provider].titleize} account (#{account.email}) disconnected.", status: :see_other
-
else
-
redirect_to settings_path(tab: "integrations"), alert: "Could not disconnect account.", status: :see_other
-
end
-
end
-
-
# POST /settings/export_data
-
def export_data
-
# Queue a background job to generate the export
-
# For now, we'll redirect with a notice
-
redirect_to settings_path(tab: "privacy"), notice: "Data export has been queued. You will receive an email when it's ready."
-
end
-
-
# DELETE /settings/account
-
def destroy_account
-
if @user.authenticate(params[:password])
-
@user.destroy
-
reset_session
-
redirect_to new_session_path, notice: "Your account has been permanently deleted.", status: :see_other
-
else
-
redirect_to settings_path(tab: "privacy"), alert: "Incorrect password. Account deletion cancelled."
-
end
-
end
-
-
# POST /settings/trigger_sync
-
def trigger_sync
-
# If account_id is provided, sync that specific account
-
# Otherwise, sync the first Google account (for backward compatibility)
-
if params[:account_id].present?
-
account = @user.connected_accounts.find_by(id: params[:account_id], provider: "google_oauth2")
-
else
-
account = @user.google_account
-
end
-
-
if account.nil?
-
redirect_to settings_path(tab: "integrations"), alert: "No Gmail account connected."
-
return
-
end
-
-
# Queue the sync job
-
GmailSyncJob.perform_later(@user, connected_account: account)
-
-
redirect_to settings_path(tab: "integrations"), notice: "Email sync started for #{account.email}. This may take a few moments."
-
end
-
-
# PATCH /settings/toggle_sync
-
def toggle_sync
-
# If account_id is provided, toggle sync for that specific account
-
# Otherwise, toggle sync for the first Google account (for backward compatibility)
-
if params[:account_id].present?
-
account = @user.connected_accounts.find_by(id: params[:account_id], provider: "google_oauth2")
-
else
-
account = @user.google_account
-
end
-
-
if account.nil?
-
respond_to do |format|
-
format.json { render json: { success: false, error: "No Gmail account connected" }, status: :unprocessable_entity }
-
format.html { redirect_to settings_path(tab: "integrations"), alert: "No Gmail account connected." }
-
end
-
return
-
end
-
-
# Toggle sync_enabled based on checkbox value
-
sync_enabled = params[:sync_enabled] == "1"
-
account.update!(sync_enabled: sync_enabled)
-
-
respond_to do |format|
-
format.json { render json: { success: true, sync_enabled: account.sync_enabled? }, status: :ok }
-
format.html { redirect_to settings_path(tab: "integrations"), notice: "Sync settings updated for #{account.email}." }
-
end
-
end
-
-
# =================================================================
-
# Work Experience Actions
-
# =================================================================
-
-
# POST /settings/work_experience
-
# Creates a new manual work experience entry
-
def create_work_experience
-
@work_experience = @user.user_work_experiences.build(work_experience_params)
-
@work_experience.source_type = :manual
-
-
if @work_experience.save
-
respond_to do |format|
-
format.html { redirect_to settings_path(tab: "work_experience"), notice: "Work experience added successfully." }
-
format.json { render json: { success: true, work_experience: work_experience_json(@work_experience) }, status: :created }
-
format.turbo_stream { load_work_experience_data }
-
end
-
else
-
respond_to do |format|
-
format.html do
-
@active_tab = "work_experience"
-
load_work_experience_data
-
render :show, status: :unprocessable_entity
-
end
-
format.json { render json: { success: false, errors: @work_experience.errors.full_messages }, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# PATCH /settings/work_experience/:id
-
# Updates a work experience entry (both AI-extracted and manual)
-
def update_work_experience
-
if @work_experience.update(work_experience_params)
-
respond_to do |format|
-
format.html { redirect_to settings_path(tab: "work_experience"), notice: "Work experience updated successfully." }
-
format.json { render json: { success: true, work_experience: work_experience_json(@work_experience) }, status: :ok }
-
format.turbo_stream { load_work_experience_data }
-
end
-
else
-
respond_to do |format|
-
format.html do
-
@active_tab = "work_experience"
-
load_work_experience_data
-
render :show, status: :unprocessable_entity
-
end
-
format.json { render json: { success: false, errors: @work_experience.errors.full_messages }, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# DELETE /settings/work_experience/:id
-
# Deletes a work experience entry
-
def destroy_work_experience
-
@work_experience.destroy
-
respond_to do |format|
-
format.html { redirect_to settings_path(tab: "work_experience"), notice: "Work experience deleted.", status: :see_other }
-
format.json { render json: { success: true }, status: :ok }
-
format.turbo_stream { load_work_experience_data }
-
end
-
end
-
-
# =================================================================
-
# Targets Actions
-
# =================================================================
-
-
# PATCH /settings/targets
-
# Updates user's target roles, companies, and domains
-
def update_targets
-
ActiveRecord::Base.transaction do
-
update_target_job_roles if params[:target_job_role_ids]
-
update_target_companies if params[:target_company_ids]
-
update_target_domains if params[:target_domain_ids]
-
end
-
-
respond_to do |format|
-
format.html { redirect_to settings_path(tab: "targets"), notice: "Targets updated successfully." }
-
format.json { render json: { success: true }, status: :ok }
-
format.turbo_stream { load_targets_data }
-
end
-
rescue ActiveRecord::RecordInvalid => e
-
respond_to do |format|
-
format.html { redirect_to settings_path(tab: "targets"), alert: "Failed to update targets: #{e.message}" }
-
format.json { render json: { success: false, error: e.message }, status: :unprocessable_entity }
-
end
-
end
-
-
# POST /settings/targets/add_role
-
# Adds a single target role
-
def add_target_role
-
job_role = JobRole.find(params[:job_role_id])
-
@user.user_target_job_roles.find_or_create_by!(job_role: job_role)
-
-
respond_to do |format|
-
format.html { redirect_to settings_path(tab: "targets"), notice: "#{job_role.title} added to target roles." }
-
format.json { render json: { success: true, job_role: { id: job_role.id, title: job_role.title, department: job_role.department_name } }, status: :ok }
-
end
-
rescue ActiveRecord::RecordNotFound
-
respond_to do |format|
-
format.html { redirect_to settings_path(tab: "targets"), alert: "Role not found." }
-
format.json { render json: { success: false, error: "Role not found" }, status: :not_found }
-
end
-
rescue ActiveRecord::RecordInvalid => e
-
respond_to do |format|
-
format.html { redirect_to settings_path(tab: "targets"), alert: e.message }
-
format.json { render json: { success: false, error: e.message }, status: :unprocessable_entity }
-
end
-
end
-
-
# DELETE /settings/targets/remove_role
-
# Removes a single target role
-
def remove_target_role
-
@user.user_target_job_roles.where(job_role_id: params[:job_role_id]).destroy_all
-
-
respond_to do |format|
-
format.html { redirect_to settings_path(tab: "targets"), notice: "Role removed from targets.", status: :see_other }
-
format.json { render json: { success: true }, status: :ok }
-
end
-
end
-
-
# POST /settings/targets/add_company
-
# Adds a single target company
-
def add_target_company
-
company = Company.find(params[:company_id])
-
@user.user_target_companies.find_or_create_by!(company: company)
-
-
respond_to do |format|
-
format.html { redirect_to settings_path(tab: "targets"), notice: "#{company.name} added to target companies." }
-
format.json { render json: { success: true, company: { id: company.id, name: company.name } }, status: :ok }
-
end
-
rescue ActiveRecord::RecordNotFound
-
respond_to do |format|
-
format.html { redirect_to settings_path(tab: "targets"), alert: "Company not found." }
-
format.json { render json: { success: false, error: "Company not found" }, status: :not_found }
-
end
-
rescue ActiveRecord::RecordInvalid => e
-
respond_to do |format|
-
format.html { redirect_to settings_path(tab: "targets"), alert: e.message }
-
format.json { render json: { success: false, error: e.message }, status: :unprocessable_entity }
-
end
-
end
-
-
# DELETE /settings/targets/remove_company
-
# Removes a single target company
-
def remove_target_company
-
@user.user_target_companies.where(company_id: params[:company_id]).destroy_all
-
-
respond_to do |format|
-
format.html { redirect_to settings_path(tab: "targets"), notice: "Company removed from targets.", status: :see_other }
-
format.json { render json: { success: true }, status: :ok }
-
end
-
end
-
-
# POST /settings/targets/add_domain
-
# Adds a single target domain
-
def add_target_domain
-
domain = Domain.find(params[:domain_id])
-
@user.user_target_domains.find_or_create_by!(domain: domain)
-
-
respond_to do |format|
-
format.html { redirect_to settings_path(tab: "targets"), notice: "#{domain.name} added to target domains." }
-
format.json { render json: { success: true, domain: { id: domain.id, name: domain.name } }, status: :ok }
-
end
-
rescue ActiveRecord::RecordNotFound
-
respond_to do |format|
-
format.html { redirect_to settings_path(tab: "targets"), alert: "Domain not found." }
-
format.json { render json: { success: false, error: "Domain not found" }, status: :not_found }
-
end
-
rescue ActiveRecord::RecordInvalid => e
-
respond_to do |format|
-
format.html { redirect_to settings_path(tab: "targets"), alert: e.message }
-
format.json { render json: { success: false, error: e.message }, status: :unprocessable_entity }
-
end
-
end
-
-
# DELETE /settings/targets/remove_domain
-
# Removes a single target domain
-
def remove_target_domain
-
@user.user_target_domains.where(domain_id: params[:domain_id]).destroy_all
-
-
respond_to do |format|
-
format.html { redirect_to settings_path(tab: "targets"), notice: "Domain removed from targets.", status: :see_other }
-
format.json { render json: { success: true }, status: :ok }
-
end
-
end
-
-
private
-
-
# Responds to successful update - JSON for AJAX, redirect for regular requests
-
# @param tab [String] The tab name
-
# @param message [String] Success message
-
def respond_to_update(tab, message)
-
respond_to do |format|
-
format.json { render json: { success: true, message: message }, status: :ok }
-
format.html { redirect_to settings_path(tab: tab), notice: message }
-
format.any { render json: { success: true, message: message }, status: :ok }
-
end
-
end
-
-
# Responds to failed update - JSON for AJAX, render form for regular requests
-
# @param tab [String] The tab name
-
def respond_to_error(tab)
-
@active_tab = tab
-
errors = tab == "profile" ? @user.errors.full_messages : @preference.errors.full_messages
-
-
respond_to do |format|
-
format.json { render json: { success: false, errors: errors }, status: :unprocessable_entity }
-
format.html { render :show, status: :unprocessable_entity }
-
format.any { render json: { success: false, errors: errors }, status: :unprocessable_entity }
-
end
-
end
-
-
# Sets the current user
-
# @return [User]
-
def set_user
-
@user = Current.user
-
end
-
-
# Sets or builds the user preference
-
# @return [UserPreference]
-
def set_preference
-
@preference = @user.preference || @user.build_preference
-
end
-
-
# Strong parameters for general settings
-
# @return [ActionController::Parameters]
-
def general_params
-
params.expect(user_preference: [ :theme, :timezone, :preferred_view ])
-
end
-
-
# Strong parameters for notification settings
-
# @return [ActionController::Parameters]
-
def notification_params
-
params.expect(user_preference: [
-
:email_notifications,
-
:email_weekly_digest,
-
:email_interview_reminders
-
])
-
end
-
-
# Strong parameters for AI preference settings
-
# @return [ActionController::Parameters]
-
def ai_preference_params
-
params.expect(user_preference: [
-
:ai_summary_enabled,
-
:ai_feedback_analysis,
-
:ai_interview_prep,
-
:ai_insights_frequency
-
])
-
end
-
-
# Strong parameters for privacy settings
-
# @return [ActionController::Parameters]
-
def privacy_params
-
params.expect(user_preference: [ :data_retention_days ])
-
end
-
-
# Strong parameters for security settings (password change)
-
# @return [ActionController::Parameters]
-
def security_params
-
params.expect(user: [ :password, :password_confirmation ])
-
end
-
-
# Strong parameters for profile settings
-
# @return [ActionController::Parameters]
-
def profile_params
-
params.require(:user).permit(
-
:name,
-
:bio,
-
:current_job_role_id,
-
:current_company_id,
-
:years_of_experience,
-
:linkedin_url,
-
:github_url,
-
:gitlab_url,
-
:twitter_url,
-
:portfolio_url
-
)
-
end
-
-
# Loads companies and job roles for the profile tab
-
# @return [void]
-
def load_profile_data
-
@companies = Company.alphabetical.limit(100)
-
@job_roles = JobRole.alphabetical.limit(100)
-
end
-
-
# Loads subscription data for the subscription tab
-
# @return [void]
-
def load_subscription_data
-
@entitlements = Billing::Entitlements.for(@user)
-
@plans = Billing::Catalog.published_plans
-
load_billing_action_urls
-
end
-
-
# Loads billing data for the billing tab
-
# @return [void]
-
def load_billing_data
-
@billing_customer = @user.billing_customers.find_by(provider: "lemonsqueezy")
-
@billing_history = load_billing_history
-
load_billing_action_urls
-
load_payment_method_info
-
end
-
-
# Loads payment method info from the latest subscription with card details.
-
#
-
# @return [void]
-
def load_payment_method_info
-
subscription_with_card = @user.billing_subscriptions
-
.where(provider: "lemonsqueezy")
-
.where.not(card_brand: nil)
-
.order(updated_at: :desc)
-
.first
-
-
@payment_method = if subscription_with_card&.card_brand.present?
-
{
-
card_brand: subscription_with_card.card_brand,
-
card_last_four: subscription_with_card.card_last_four
-
}
-
end
-
end
-
-
# Loads billing history using orders as the source of truth.
-
#
-
# Each order represents a payment. Orders linked to subscriptions show invoice button,
-
# one-time orders (Sprint) only show receipt button.
-
#
-
# @return [Array<Hash>]
-
def load_billing_history
-
orders = @user.billing_orders
-
.includes(:subscription)
-
.where(provider: "lemonsqueezy")
-
.where(status: "paid")
-
.order(created_at: :desc)
-
.limit(15)
-
-
@subscription_history = []
-
@order_history = []
-
-
history = orders.map do |order|
-
entry = build_billing_entry_from_order(order)
-
-
if order.subscription.present?
-
@subscription_history << entry
-
else
-
@order_history << entry
-
end
-
-
entry
-
end
-
-
history.sort_by { |e| e[:date] || Time.at(0) }.reverse
-
end
-
-
# Builds a billing history entry from an order.
-
#
-
# @param order [Billing::Order]
-
# @return [Hash]
-
def build_billing_entry_from_order(order)
-
subscription = order.subscription
-
plan = subscription&.plan || resolve_plan_for_order(order)
-
is_one_time = plan&.one_time? || subscription.nil?
-
-
# Get product name from order data (actual purchase), fallback to plan name
-
product_name = order.metadata&.dig("raw", "first_order_item", "product_name") ||
-
plan&.name ||
-
"Payment"
-
-
{
-
type: is_one_time ? :order : :subscription,
-
date: order.created_at,
-
plan_name: product_name,
-
amount: (order.total_cents || 0) / 100.0,
-
currency: order.currency || "usd",
-
status: order.status,
-
order_id: order.external_order_id,
-
order_number: order.order_number,
-
receipt_url: order.receipt_url,
-
invoice_url: subscription&.latest_invoice_url, # Only subscriptions have invoices
-
is_one_time: is_one_time
-
}
-
end
-
-
# Resolves the plan for an order based on variant_id in metadata.
-
#
-
# @param order [Billing::Order]
-
# @return [Billing::Plan, nil]
-
def resolve_plan_for_order(order)
-
variant_id = order.metadata&.dig("raw", "first_order_item", "variant_id")
-
return nil if variant_id.blank?
-
-
mapping = Billing::ProviderMapping.find_by(provider: "lemonsqueezy", external_variant_id: variant_id.to_s)
-
mapping&.plan
-
end
-
-
# Loads billing action URLs from stored metadata.
-
#
-
# @return [void]
-
def load_billing_action_urls
-
@billing_customer ||= @user.billing_customers.find_by(provider: "lemonsqueezy")
-
subscription = latest_billing_subscription
-
@billing_portal_url = @billing_customer&.customer_portal_url || subscription&.customer_portal_url
-
@billing_update_payment_url = subscription&.update_payment_method_url
-
@billing_update_subscription_url = subscription&.update_subscription_url
-
end
-
-
# Returns the most recently updated billing subscription.
-
#
-
# @return [Billing::Subscription, nil]
-
def latest_billing_subscription
-
@latest_billing_subscription ||= @user.billing_subscriptions
-
.where(provider: "lemonsqueezy")
-
.order(updated_at: :desc)
-
.first
-
end
-
-
# =================================================================
-
# Work Experience Data & Params
-
# =================================================================
-
-
# Sets work experience for update/destroy actions
-
# @return [UserWorkExperience]
-
def set_work_experience
-
@work_experience = @user.user_work_experiences.find(params[:id])
-
rescue ActiveRecord::RecordNotFound
-
redirect_to settings_path(tab: "work_experience"), alert: "Work experience not found."
-
end
-
-
# Loads work experience data for the work_experience tab
-
# @return [void]
-
def load_work_experience_data
-
@work_experiences = @user.user_work_experiences
-
.reverse_chronological
-
.includes(:company, :job_role, :skill_tags)
-
@new_work_experience = @user.user_work_experiences.build
-
@companies = Company.alphabetical.limit(200)
-
@job_roles = JobRole.alphabetical.limit(200)
-
end
-
-
# Strong parameters for work experience
-
# @return [ActionController::Parameters]
-
def work_experience_params
-
params.require(:user_work_experience).permit(
-
:company_name,
-
:role_title,
-
:company_id,
-
:job_role_id,
-
:start_date,
-
:end_date,
-
:current,
-
:responsibilities,
-
:highlights
-
)
-
end
-
-
# Serializes work experience for JSON response
-
# @param work_experience [UserWorkExperience]
-
# @return [Hash]
-
def work_experience_json(work_experience)
-
{
-
id: work_experience.id,
-
company_name: work_experience.display_company_name,
-
role_title: work_experience.display_role_title,
-
start_date: work_experience.start_date,
-
end_date: work_experience.end_date,
-
current: work_experience.current,
-
source_type: work_experience.source_type,
-
responsibilities: work_experience.responsibilities,
-
highlights: work_experience.highlights
-
}
-
end
-
-
# =================================================================
-
# Targets Data & Params
-
# =================================================================
-
-
# Loads targets data for the targets tab
-
# @return [void]
-
def load_targets_data
-
@target_job_roles = @user.target_job_roles.includes(:category).alphabetical
-
@target_companies = @user.target_companies.alphabetical
-
@target_domains = @user.target_domains.alphabetical
-
-
@departments = Category.departments
-
@job_roles_by_department = JobRole.alphabetical
-
.includes(:category)
-
.group_by { |r| r.category&.name || "Uncategorized" }
-
-
@all_companies = Company.alphabetical
-
@all_domains = Domain.alphabetical
-
end
-
-
# Updates target job roles
-
# @return [void]
-
def update_target_job_roles
-
new_ids = Array(params[:target_job_role_ids]).map(&:to_i).reject(&:zero?)
-
current_ids = @user.target_job_role_ids
-
-
# Remove deselected
-
to_remove = current_ids - new_ids
-
@user.user_target_job_roles.where(job_role_id: to_remove).destroy_all if to_remove.any?
-
-
# Add new
-
to_add = new_ids - current_ids
-
to_add.each do |role_id|
-
@user.user_target_job_roles.find_or_create_by!(job_role_id: role_id)
-
end
-
end
-
-
# Updates target companies
-
# @return [void]
-
def update_target_companies
-
new_ids = Array(params[:target_company_ids]).map(&:to_i).reject(&:zero?)
-
current_ids = @user.target_company_ids
-
-
# Remove deselected
-
to_remove = current_ids - new_ids
-
@user.user_target_companies.where(company_id: to_remove).destroy_all if to_remove.any?
-
-
# Add new
-
to_add = new_ids - current_ids
-
to_add.each do |company_id|
-
@user.user_target_companies.find_or_create_by!(company_id: company_id)
-
end
-
end
-
-
# Updates target domains
-
# @return [void]
-
def update_target_domains
-
new_ids = Array(params[:target_domain_ids]).map(&:to_i).reject(&:zero?)
-
current_ids = @user.target_domain_ids
-
-
# Remove deselected
-
to_remove = current_ids - new_ids
-
@user.user_target_domains.where(domain_id: to_remove).destroy_all if to_remove.any?
-
-
# Add new
-
to_add = new_ids - current_ids
-
to_add.each do |domain_id|
-
@user.user_target_domains.find_or_create_by!(domain_id: domain_id)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# Controller for the Signals view (rebranded Inbox)
-
# Displays synced emails with AI-extracted intelligence and smart actions
-
# Supports split-pane layout with Turbo Frames
-
class SignalsController < ApplicationController
-
before_action :set_synced_email, only: [ :show, :match_application, :ignore, :execute_action ]
-
-
# GET /signals
-
#
-
# Main signals view with split-pane layout
-
def index
-
@emails = current_user_emails
-
.includes(:interview_application, :email_sender, interview_application: [ :company, :job_role ])
-
.order(email_date: :desc)
-
-
# Apply relevance filter (default to relevant emails only)
-
@current_relevance = params[:relevance] || "relevant"
-
@emails = filter_by_relevance(@emails)
-
-
# Apply other filters
-
@emails = filter_by_type(@emails)
-
@emails = filter_by_status(@emails)
-
@emails = filter_by_company(@emails)
-
@emails = search_emails(@emails)
-
-
# For "all" tab, show unified chronological list; otherwise split by matched/unmatched
-
@show_unified_list = @current_relevance == "all"
-
-
if @show_unified_list
-
# Unified chronological list - group by thread and paginate all together
-
all_by_thread = group_emails_by_thread(@emails)
-
@pagy_all, @all_emails = pagy_array(all_by_thread, limit: 20, page_param: :page)
-
@grouped_emails = {}
-
@unmatched_emails = []
-
@pagy_unmatched = nil
-
else
-
# Split view: unmatched emails first, then matched grouped by application
-
@grouped_emails = group_emails_by_application(@emails)
-
-
# Get unmatched emails grouped by thread (only latest email per thread)
-
unmatched_by_thread = group_emails_by_thread(@emails.unmatched)
-
@pagy_unmatched, @unmatched_emails = pagy_array(unmatched_by_thread, limit: 15, page_param: :unmatched_page)
-
@all_emails = []
-
@pagy_all = nil
-
end
-
-
# Load filter options
-
@email_types = SyncedEmail::EMAIL_TYPES
-
@companies = Company.joins(:interview_applications)
-
.where(interview_applications: { user_id: Current.user.id })
-
.distinct
-
.alphabetical
-
-
# Calculate counts for relevance tabs
-
@relevance_counts = calculate_relevance_counts
-
-
# If email_id param, pre-select that email
-
@selected_email = current_user_emails.find_by(id: params[:email_id]) if params[:email_id]
-
-
# Respond to turbo frame requests for email_list (search/filter without full page reload)
-
respond_to do |format|
-
format.html do
-
if turbo_frame_request_id == "email_list"
-
render inline: <<~ERB, locals: { grouped_emails: @grouped_emails, unmatched_emails: @unmatched_emails, pagy_unmatched: @pagy_unmatched, selected_email_id: @selected_email&.id, show_unified_list: @show_unified_list, all_emails: @all_emails, pagy_all: @pagy_all }
-
<%= turbo_frame_tag "email_list", class: "flex-1 overflow-y-auto" do %>
-
<%= render "signals/email_list", grouped_emails: grouped_emails, unmatched_emails: unmatched_emails, pagy_unmatched: pagy_unmatched, selected_email_id: selected_email_id, show_unified_list: show_unified_list, all_emails: all_emails, pagy_all: pagy_all %>
-
<% end %>
-
ERB
-
else
-
render :index
-
end
-
end
-
end
-
end
-
-
# GET /signals/:id
-
#
-
# Show email detail with extracted signals - responds to Turbo Frame for split-pane
-
def show
-
@application = @email.interview_application
-
@thread_emails = @email.thread_emails.includes(:email_sender)
-
-
respond_to do |format|
-
format.html do
-
# Full page render for direct access or mobile
-
render :show
-
end
-
format.turbo_stream do
-
# Turbo Frame update for split-pane
-
render turbo_stream: turbo_stream.update(
-
"email_detail",
-
partial: "signals/detail_panel",
-
locals: { email: @email, thread_emails: @thread_emails, application: @application }
-
)
-
end
-
end
-
end
-
-
# GET /signals/application_emails
-
#
-
# Turbo Frame endpoint used to progressively load matched emails (latest per thread)
-
# for a single interview application group in the Signals list.
-
#
-
# Params:
-
# - interview_application_id (required)
-
# - limit (optional, defaults to 5; increases in batches of 5)
-
# - plus any existing list filters (relevance, type, status, company_id, q)
-
def application_emails
-
application = Current.user.interview_applications.find(params[:interview_application_id])
-
-
limit = params[:limit].to_i
-
limit = 5 if limit <= 0
-
limit = [ limit, 50 ].min
-
-
emails = current_user_emails
-
.includes(:interview_application, :email_sender, interview_application: [ :company, :job_role ])
-
.order(email_date: :desc)
-
-
@current_relevance = params[:relevance] || "relevant"
-
emails = filter_by_relevance(emails)
-
emails = filter_by_type(emails)
-
emails = filter_by_status(emails)
-
emails = filter_by_company(emails)
-
emails = search_emails(emails)
-
-
emails = emails
-
.matched
-
.where(interview_application_id: application.id)
-
-
# Match the list behavior: show only the latest email per thread.
-
unique_threads = {}
-
emails.each do |email|
-
thread_key = email.thread_id || email.id
-
if unique_threads[thread_key].nil? || (email.email_date && email.email_date > unique_threads[thread_key].email_date)
-
unique_threads[thread_key] = email
-
end
-
end
-
-
thread_emails = unique_threads.values
-
.sort_by { |e| e.email_date || e.created_at }
-
.reverse
-
-
frame_id = "signals_application_#{application.id}_emails"
-
-
render inline: <<~ERB, locals: { application: application, emails: thread_emails, limit: limit, frame_id: frame_id, selected_email_id: params[:selected_email_id].presence&.to_i }
-
<%= turbo_frame_tag frame_id do %>
-
<%= render "signals/application_emails", application: application, emails: emails, limit: limit, frame_id: frame_id, selected_email_id: selected_email_id %>
-
<% end %>
-
ERB
-
end
-
-
# PATCH /signals/:id/match_application
-
#
-
# Match email to an interview application
-
def match_application
-
application = Current.user.interview_applications.find_by(id: params[:application_id])
-
-
if application && @email.match_to_application!(application)
-
# Also match other emails in the same thread
-
match_thread_emails(application) if @email.thread_id.present?
-
-
# Reprocess this email now that it is matched
-
Signals::EmailStateOrchestrator.new(@email).call
-
-
respond_to do |format|
-
format.html { redirect_to signals_path, notice: "Signal matched to #{application.company.name}." }
-
format.turbo_stream do
-
flash.now[:notice] = "Signal matched to #{application.company.name}."
-
@thread_emails = @email.thread_emails.includes(:email_sender)
-
reload_email_list_data
-
render turbo_stream: [
-
turbo_stream.update("email_detail",
-
partial: "signals/detail_panel",
-
locals: { email: @email, thread_emails: @thread_emails, application: application }
-
),
-
turbo_stream.update("email_list",
-
partial: "signals/email_list",
-
locals: { grouped_emails: @grouped_emails, unmatched_emails: @unmatched_emails, pagy_unmatched: @pagy_unmatched, selected_email_id: @email.id }
-
),
-
turbo_stream.update("flash",
-
partial: "shared/flash"
-
),
-
turbo_stream.update("email_stats",
-
html: email_stats_html
-
)
-
]
-
end
-
format.json { render json: { success: true, application_id: application.id } }
-
end
-
else
-
respond_to do |format|
-
format.html { redirect_to signals_path, alert: "Could not match signal to application." }
-
format.turbo_stream do
-
flash.now[:alert] = "Could not match signal to application."
-
render turbo_stream: [
-
turbo_stream.update("email_detail",
-
html: "<div class='p-4 text-red-600'>Could not match signal</div>"
-
),
-
turbo_stream.update("flash",
-
partial: "shared/flash"
-
)
-
]
-
end
-
format.json { render json: { success: false }, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# PATCH /signals/:id/ignore
-
#
-
# Mark email as not interview-related
-
def ignore
-
if @email.ignore!
-
respond_to do |format|
-
format.html { redirect_to signals_path, notice: "Signal dismissed." }
-
format.turbo_stream do
-
state = reload_email_list_data_from_referer
-
render turbo_stream: [
-
turbo_stream.update("email_detail",
-
partial: "signals/empty_state"
-
),
-
turbo_stream.update("email_list",
-
partial: "signals/email_list",
-
locals: {
-
grouped_emails: state[:grouped_emails],
-
unmatched_emails: state[:unmatched_emails],
-
pagy_unmatched: state[:pagy_unmatched],
-
selected_email_id: nil,
-
show_unified_list: state[:show_unified_list],
-
all_emails: state[:all_emails],
-
pagy_all: state[:pagy_all]
-
}
-
),
-
turbo_stream.update("email_stats",
-
html: email_stats_html
-
)
-
]
-
end
-
format.json { render json: { success: true } }
-
end
-
else
-
respond_to do |format|
-
format.html { redirect_to signals_path, alert: "Could not dismiss signal." }
-
format.turbo_stream do
-
@thread_emails = @email.thread_emails.includes(:email_sender)
-
render turbo_stream: turbo_stream.update(
-
"email_detail",
-
partial: "signals/detail_panel",
-
locals: { email: @email, thread_emails: @thread_emails, application: @email.interview_application }
-
)
-
end
-
format.json { render json: { success: false }, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# POST /signals/:id/execute_action
-
#
-
# Execute a signal action (start_application, schedule_interview, etc.)
-
def execute_action
-
action_type = params[:action_type]
-
executor = Signals::ActionExecutor.new(@email, Current.user, action_type, params)
-
result = executor.execute
-
-
respond_to do |format|
-
if result[:success]
-
if result[:redirect_url]
-
# External redirect (scheduling link, careers page, etc.)
-
format.html { redirect_to result[:redirect_url], allow_other_host: result[:external] }
-
format.turbo_stream do
-
render turbo_stream: turbo_stream.action(:redirect, result[:redirect_url])
-
end
-
format.json { render json: result }
-
elsif result[:redirect_path]
-
# Internal redirect (new application page) - flash will show on target page
-
flash[:notice] = result[:message]
-
format.html { redirect_to result[:redirect_path], status: :see_other }
-
format.turbo_stream { redirect_to result[:redirect_path], status: :see_other }
-
format.json { render json: result }
-
else
-
# Action completed, refresh the view
-
format.html { redirect_to signals_path, notice: result[:message] }
-
format.turbo_stream do
-
@thread_emails = @email.reload.thread_emails.includes(:email_sender)
-
reload_email_list_data
-
render turbo_stream: [
-
turbo_stream.update("email_detail",
-
partial: "signals/detail_panel",
-
locals: { email: @email, thread_emails: @thread_emails, application: @email.interview_application }
-
),
-
turbo_stream.update("email_list",
-
partial: "signals/email_list",
-
locals: { grouped_emails: @grouped_emails, unmatched_emails: @unmatched_emails, pagy_unmatched: @pagy_unmatched, selected_email_id: @email.id }
-
),
-
turbo_stream.update("flash",
-
partial: "shared/flash",
-
locals: { notice: result[:message] }
-
)
-
]
-
end
-
format.json { render json: result }
-
end
-
else
-
format.html { redirect_to signal_path(@email), alert: result[:error] }
-
format.turbo_stream do
-
render turbo_stream: turbo_stream.update("flash",
-
partial: "shared/flash",
-
locals: { alert: result[:error] }
-
)
-
end
-
format.json { render json: result, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
private
-
-
# Sets the email for member actions
-
#
-
# @return [SyncedEmail]
-
def set_synced_email
-
@email = current_user_emails.find(params[:id])
-
rescue ActiveRecord::RecordNotFound
-
respond_to do |format|
-
format.html { redirect_to signals_path, alert: "Signal not found." }
-
format.turbo_stream do
-
render turbo_stream: turbo_stream.update(
-
"email_detail",
-
partial: "signals/empty_state"
-
)
-
end
-
end
-
end
-
-
# Returns the current user's synced emails
-
#
-
# @return [ActiveRecord::Relation]
-
def current_user_emails
-
Current.user.synced_emails
-
end
-
-
# Filters emails by relevance (all, relevant, interviews, opportunities)
-
#
-
# @param emails [ActiveRecord::Relation]
-
# @return [ActiveRecord::Relation]
-
def filter_by_relevance(emails)
-
case @current_relevance
-
when "all"
-
emails.visible # Excludes ignored and auto_ignored
-
when "interviews"
-
emails.interview_related.visible
-
when "opportunities"
-
emails.potential_opportunities.visible
-
else # "relevant" (default)
-
emails.relevant
-
end
-
end
-
-
# Calculates counts for relevance filter tabs
-
#
-
# @return [Hash] Counts by relevance type
-
def calculate_relevance_counts
-
base = current_user_emails
-
{
-
all: base.visible.count,
-
relevant: base.relevant.count,
-
interviews: base.interview_related.visible.count,
-
opportunities: base.potential_opportunities.visible.count
-
}
-
end
-
-
# Filters emails by type
-
#
-
# @param emails [ActiveRecord::Relation]
-
# @return [ActiveRecord::Relation]
-
def filter_by_type(emails)
-
return emails unless params[:type].present?
-
-
emails.by_type(params[:type])
-
end
-
-
# Filters emails by status (matched/unmatched/all)
-
#
-
# @param emails [ActiveRecord::Relation]
-
# @return [ActiveRecord::Relation]
-
def filter_by_status(emails)
-
case params[:status]
-
when "matched"
-
emails.matched
-
when "unmatched"
-
emails.unmatched
-
when "pending"
-
emails.pending
-
when "ignored"
-
emails.ignored
-
else
-
emails
-
end
-
end
-
-
# Filters emails by company
-
#
-
# @param emails [ActiveRecord::Relation]
-
# @return [ActiveRecord::Relation]
-
def filter_by_company(emails)
-
return emails unless params[:company_id].present?
-
-
company = Company.find_by(id: params[:company_id])
-
return emails unless company
-
-
application_ids = Current.user.interview_applications
-
.where(company: company)
-
.pluck(:id)
-
-
emails.where(interview_application_id: application_ids)
-
end
-
-
# Searches emails by query
-
#
-
# @param emails [ActiveRecord::Relation]
-
# @return [ActiveRecord::Relation]
-
def search_emails(emails)
-
return emails unless params[:q].present?
-
-
query = "%#{params[:q]}%"
-
emails.where(
-
"subject ILIKE :q OR from_email ILIKE :q OR from_name ILIKE :q OR snippet ILIKE :q",
-
q: query
-
)
-
end
-
-
# Groups emails by their associated application
-
# Returns the latest email from each thread grouped by application
-
#
-
# @param emails [ActiveRecord::Relation]
-
# @return [Hash]
-
def group_emails_by_application(emails)
-
# Get unique threads, keeping only the latest email from each thread
-
unique_threads = {}
-
emails.matched.each do |email|
-
thread_key = email.thread_id || email.id
-
if unique_threads[thread_key].nil? || (email.email_date && email.email_date > unique_threads[thread_key].email_date)
-
unique_threads[thread_key] = email
-
end
-
end
-
-
# Group by application
-
unique_threads.values
-
.group_by(&:interview_application)
-
.transform_values { |app_emails| app_emails.sort_by { |e| e.email_date || e.created_at }.reverse }
-
.sort_by { |app, _| app&.company&.name || "" }
-
.to_h
-
end
-
-
# Groups emails by thread, keeping only the latest email from each thread
-
#
-
# @param emails [ActiveRecord::Relation]
-
# @return [Array<SyncedEmail>]
-
def group_emails_by_thread(emails)
-
unique_threads = {}
-
emails.each do |email|
-
thread_key = email.thread_id.presence || "single_#{email.id}"
-
if unique_threads[thread_key].nil? || (email.email_date && email.email_date > unique_threads[thread_key].email_date)
-
unique_threads[thread_key] = email
-
end
-
end
-
-
unique_threads.values.sort_by { |e| e.email_date || e.created_at }.reverse
-
end
-
-
# Matches all emails in the same thread to an application
-
#
-
# @param application [InterviewApplication]
-
# @return [void]
-
def match_thread_emails(application)
-
current_user_emails
-
.where(thread_id: @email.thread_id)
-
.where.not(id: @email.id)
-
.update_all(interview_application_id: application.id, status: :processed)
-
end
-
-
# Reloads the email list data for Turbo Stream updates
-
#
-
# @return [void]
-
def reload_email_list_data
-
emails = current_user_emails
-
.includes(:interview_application, :email_sender, interview_application: [ :company, :job_role ])
-
.order(email_date: :desc)
-
-
@grouped_emails = group_emails_by_application(emails)
-
unmatched_by_thread = group_emails_by_thread(emails.unmatched)
-
@pagy_unmatched, @unmatched_emails = pagy_array(unmatched_by_thread, limit: 15, page_param: :unmatched_page)
-
end
-
-
# Reloads the email list data using the Signals page query params.
-
#
-
# Turbo Stream actions like `ignore` are invoked from within the detail frame
-
# (`/signals/:id`), so the request params typically DO NOT include the current
-
# list filters (relevance/status/search/etc.). We therefore parse the referer
-
# (the Signals page URL) and rebuild the list state consistently.
-
#
-
# @return [Hash]
-
def reload_email_list_data_from_referer
-
list_params = list_params_from_referer
-
current_relevance = list_params[:relevance].presence || "relevant"
-
-
emails = current_user_emails
-
.includes(:interview_application, :email_sender, interview_application: [ :company, :job_role ])
-
.order(email_date: :desc)
-
-
emails = filter_emails_for_list(emails, list_params, current_relevance: current_relevance)
-
-
show_unified_list = current_relevance == "all"
-
-
if show_unified_list
-
all_by_thread = group_emails_by_thread(emails)
-
pagy_all, all_emails = pagy_array(
-
all_by_thread,
-
limit: 20,
-
page: list_params[:page].presence,
-
page_param: :page
-
)
-
{
-
show_unified_list: true,
-
grouped_emails: {},
-
unmatched_emails: [],
-
pagy_unmatched: nil,
-
all_emails: all_emails,
-
pagy_all: pagy_all
-
}
-
else
-
grouped_emails = group_emails_by_application(emails)
-
unmatched_by_thread = group_emails_by_thread(emails.unmatched)
-
pagy_unmatched, unmatched_emails = pagy_array(
-
unmatched_by_thread,
-
limit: 15,
-
page: list_params[:unmatched_page].presence,
-
page_param: :unmatched_page
-
)
-
{
-
show_unified_list: false,
-
grouped_emails: grouped_emails,
-
unmatched_emails: unmatched_emails,
-
pagy_unmatched: pagy_unmatched,
-
all_emails: [],
-
pagy_all: nil
-
}
-
end
-
end
-
-
# Returns HTML for the email stats footer
-
#
-
# @return [String]
-
def email_stats_html
-
needs_review = Current.user.synced_emails.needs_review.count
-
matched = Current.user.synced_emails.matched.count
-
"<span>#{needs_review} signals need attention</span><span>#{matched} matched</span>"
-
end
-
-
# Extracts list params from the referer URL (best-effort).
-
#
-
# @return [ActionController::Parameters]
-
def list_params_from_referer
-
return ActionController::Parameters.new({}) if request.referer.blank?
-
-
uri = URI.parse(request.referer)
-
parsed = Rack::Utils.parse_nested_query(uri.query.to_s)
-
ActionController::Parameters.new(parsed)
-
rescue URI::InvalidURIError
-
ActionController::Parameters.new({})
-
end
-
-
# Applies the same filters used by `index`, but based on explicit params.
-
#
-
# @param emails [ActiveRecord::Relation]
-
# @param list_params [ActionController::Parameters]
-
# @param current_relevance [String]
-
# @return [ActiveRecord::Relation]
-
def filter_emails_for_list(emails, list_params, current_relevance:)
-
filtered = case current_relevance
-
when "all"
-
emails.visible
-
when "interviews"
-
emails.interview_related.visible
-
when "opportunities"
-
emails.potential_opportunities.visible
-
else
-
emails.relevant
-
end
-
-
if list_params[:type].present?
-
filtered = filtered.by_type(list_params[:type])
-
end
-
-
case list_params[:status]
-
when "matched"
-
filtered = filtered.matched
-
when "unmatched"
-
filtered = filtered.unmatched
-
when "pending"
-
filtered = filtered.pending
-
when "ignored"
-
filtered = filtered.ignored
-
end
-
-
if list_params[:company_id].present?
-
company = Company.find_by(id: list_params[:company_id])
-
if company
-
application_ids = Current.user.interview_applications
-
.where(company: company)
-
.pluck(:id)
-
filtered = filtered.where(interview_application_id: application_ids)
-
end
-
end
-
-
if list_params[:q].present?
-
query = "%#{list_params[:q]}%"
-
filtered = filtered.where(
-
"subject ILIKE :q OR from_email ILIKE :q OR from_name ILIKE :q OR snippet ILIKE :q",
-
q: query
-
)
-
end
-
-
filtered
-
end
-
end
-
# frozen_string_literal: true
-
-
# Controller for skill tag autocomplete + JSON create.
-
# Used by the shared autocomplete component.
-
class SkillTagsController < ApplicationController
-
# GET /skill_tags
-
def index
-
@skill_tags = SkillTag.enabled.alphabetical
-
-
if params[:q].present?
-
@skill_tags = @skill_tags.where("name ILIKE ?", "%#{params[:q]}%")
-
end
-
-
@skill_tags = @skill_tags.limit(50)
-
-
respond_to do |format|
-
format.html
-
format.json { render json: @skill_tags }
-
end
-
end
-
-
# GET /skill_tags/autocomplete
-
def autocomplete
-
query = params[:q].to_s.strip
-
-
@skill_tags = if query.present?
-
SkillTag.enabled.where("name ILIKE ?", "%#{query}%")
-
.alphabetical
-
.limit(10)
-
else
-
SkillTag.enabled.alphabetical.limit(10)
-
end
-
-
render json: @skill_tags.map { |t| { id: t.id, name: t.name, category: t.category_name } }
-
end
-
-
# POST /skill_tags
-
def create
-
return head :not_acceptable unless request.format.json?
-
-
name = (params[:name] || params.dig(:skill_tag, :name))&.strip
-
return render json: { errors: [ "Name is required" ] }, status: :unprocessable_entity if name.blank?
-
-
# Find by case-insensitive name
-
@skill_tag = SkillTag.where("LOWER(name) = ?", name.downcase).first
-
-
if @skill_tag.nil?
-
@skill_tag = SkillTag.new(name: name)
-
if @skill_tag.save
-
render json: { id: @skill_tag.id, name: @skill_tag.name }, status: :created
-
else
-
render json: { errors: @skill_tag.errors.full_messages }, status: :unprocessable_entity
-
end
-
else
-
@skill_tag.update!(disabled_at: nil) if @skill_tag.disabled?
-
render json: { id: @skill_tag.id, name: @skill_tag.name }, status: :ok
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# Controller for the Skills Dashboard
-
#
-
# Provides a comprehensive view of the user's skill profile aggregated
-
# across all resumes with visualizations and insights.
-
class SkillsController < ApplicationController
-
# GET /skills
-
#
-
# Main skills dashboard with aggregated skill profile
-
def index
-
@user_skills = Current.user.user_skills
-
.includes(:skill_tag)
-
.by_level_desc
-
-
@skills_by_category = @user_skills.group_by(&:category)
-
@top_skills = UserSkill.top_skills(Current.user, limit: 10)
-
@development_areas = UserSkill.development_areas(Current.user, limit: 5)
-
@skill_stats = calculate_skill_stats
-
@category_stats = calculate_category_stats
-
@resume_coverage = calculate_resume_coverage
-
@skills_by_experience = calculate_skills_by_experience
-
-
@merged_strengths = merged_strengths_for(Current.user)
-
@resume_domains = aggregated_label_counts(Current.user.user_resumes.analyzed.pluck(:domains).flatten)
-
end
-
-
# GET /skills/:id
-
#
-
# Skill detail view showing proficiency + evidence of use in work history.
-
def show
-
@skill_tag = SkillTag.find(params[:id])
-
-
@user_skill = Current.user.user_skills.includes(:skill_tag).find_by(skill_tag_id: @skill_tag.id)
-
-
@experience_skill_rows = UserWorkExperienceSkill
-
.includes(:skill_tag, user_work_experience: [ :company, :job_role ])
-
.joins(:user_work_experience)
-
.where(user_work_experiences: { user_id: Current.user.id }, skill_tag_id: @skill_tag.id)
-
.order(Arel.sql("COALESCE(user_work_experiences.end_date, user_work_experiences.start_date) DESC NULLS LAST"), created_at: :desc)
-
-
@resume_sources = UserResume
-
.joins(resume_work_experiences: :resume_work_experience_skills)
-
.where(user_id: Current.user.id, resume_work_experience_skills: { skill_tag_id: @skill_tag.id })
-
.distinct
-
.order(created_at: :desc)
-
end
-
-
private
-
-
# Calculates overall skill statistics
-
#
-
# @return [Hash] Skill stats
-
def calculate_skill_stats
-
skills = Current.user.user_skills
-
-
{
-
total: skills.count,
-
strong: skills.strong_skills.count,
-
moderate: skills.moderate_skills.count,
-
developing: skills.developing_skills.count,
-
average_level: skills.average(:aggregated_level)&.round(1) || 0,
-
most_demonstrated: skills.most_demonstrated.first
-
}
-
end
-
-
# Calculates stats by category
-
#
-
# @return [Array<Hash>] Category stats sorted by count
-
def calculate_category_stats
-
Current.user.user_skills
-
.group(:category)
-
.select("category, COUNT(*) as count, AVG(aggregated_level) as avg_level")
-
.order("count DESC")
-
.map do |row|
-
{
-
category: row.category || "Other",
-
count: row.count,
-
avg_level: row.avg_level&.round(1) || 0
-
}
-
end
-
end
-
-
# Calculates resume coverage for skills
-
#
-
# @return [Hash] Resume coverage data
-
def calculate_resume_coverage
-
resumes = Current.user.user_resumes.analyzed
-
total_resumes = resumes.count
-
-
{
-
total_resumes: total_resumes,
-
resumes_with_skills: resumes.joins(:resume_skills).distinct.count,
-
avg_skills_per_resume: total_resumes > 0 ? (ResumeSkill.joins(:user_resume).where(user_resumes: { user_id: Current.user.id }).count.to_f / total_resumes).round(1) : 0
-
}
-
end
-
-
# Calculates experience-based usage for skills (skills used in distinct work experiences).
-
#
-
# @return [Array<Hash>]
-
def calculate_skills_by_experience
-
rows = UserWorkExperienceSkill
-
.joins(user_work_experience: :user)
-
.where(user_work_experiences: { user_id: Current.user.id })
-
.group(:skill_tag_id)
-
.select(
-
:skill_tag_id,
-
Arel.sql("COUNT(DISTINCT user_work_experience_id) AS experience_count"),
-
Arel.sql("MAX(last_used_on) AS last_used_on")
-
)
-
.order(Arel.sql("experience_count DESC"), Arel.sql("last_used_on DESC NULLS LAST"))
-
.limit(12)
-
-
skill_tags = SkillTag.where(id: rows.map(&:skill_tag_id)).index_by(&:id)
-
-
rows.map do |row|
-
tag = skill_tags[row.skill_tag_id]
-
{
-
skill_tag_id: row.skill_tag_id,
-
name: tag&.name || "Unknown",
-
experience_count: row.try(:experience_count).to_i,
-
last_used_on: row.try(:last_used_on)
-
}
-
end
-
end
-
-
def aggregated_label_counts(labels)
-
Labels::DedupeService
-
.new(labels, similarity_threshold: 0.82, overlap_threshold: 0.75)
-
.grouped_counts
-
end
-
-
def merged_strengths_for(user)
-
resume_counts = aggregated_label_counts(user.user_resumes.analyzed.pluck(:strengths).flatten)
-
-
feedback_strengths = ProfileInsightsService.new(user).generate_insights[:strengths] || []
-
feedback_counts = {}
-
feedback_strengths.each do |row|
-
name = row[:name] || row["name"]
-
count = row[:count] || row["count"] || 0
-
key = normalize_label_key(name)
-
next if key.blank?
-
-
feedback_counts[key] ||= { label: name.to_s.strip, count: 0 }
-
feedback_counts[key][:count] += count.to_i
-
end
-
-
keys = (resume_counts.keys + feedback_counts.keys).uniq
-
merged = keys.map do |key|
-
resume = resume_counts[key]
-
feedback = feedback_counts[key]
-
-
label = resume&.dig(:label).presence || feedback&.dig(:label).presence || key
-
resume_count = resume&.dig(:count).to_i
-
feedback_count = feedback&.dig(:count).to_i
-
sources = []
-
sources << "resume" if resume_count.positive?
-
sources << "feedback" if feedback_count.positive?
-
-
{
-
key: key,
-
label: label,
-
total_count: resume_count + feedback_count,
-
resume_count: resume_count,
-
feedback_count: feedback_count,
-
sources: sources
-
}
-
end
-
-
merged.sort_by { |h| -h[:total_count].to_i }
-
end
-
-
def normalize_label_key(label)
-
# Kept for backward compatibility (used by merged_strengths_for feedback keys).
-
ActiveSupport::Inflector.transliterate(label.to_s)
-
.downcase
-
.tr("&", "and")
-
.gsub(/[^a-z0-9\s]/, " ")
-
.gsub(/\s+/, " ")
-
.strip
-
end
-
end
-
# frozen_string_literal: true
-
-
# Controller for managing user resumes and skill profiles
-
#
-
# Provides CRUD operations for resumes and displays the aggregated skill profile
-
class UserResumesController < ApplicationController
-
before_action :set_user_resume, only: [ :show, :edit, :update, :destroy, :reanalyze ]
-
-
# GET /resumes
-
#
-
# Main resumes view with CV list and aggregated skill profile
-
def index
-
@user_resumes = current_user_resumes.recent_first.includes(:target_job_roles, :target_companies)
-
@user_skills = Current.user.user_skills.includes(:skill_tag).by_level_desc
-
@skills_by_category = @user_skills.group_by(&:category)
-
@job_roles = JobRole.order(:title)
-
@companies = Company.order(:name)
-
-
@merged_strengths = merged_strengths_for(Current.user)
-
@resume_domains = aggregated_label_counts(Current.user.user_resumes.analyzed.pluck(:domains).flatten)
-
end
-
-
# GET /resumes/:id
-
#
-
# Show resume details with extracted skills for review
-
def show
-
base_skills = @user_resume.resume_skills
-
.includes(:skill_tag)
-
.order(Arel.sql("COALESCE(user_level, model_level) DESC"))
-
-
@pagy, @resume_skills = pagy(base_skills, limit: 10)
-
@skills_by_category = @resume_skills.group_by(&:category)
-
@total_skills_count = base_skills.count
-
@work_experiences = @user_resume.resume_work_experiences.includes(:company, :job_role, resume_work_experience_skills: :skill_tag).reverse_chronological
-
-
respond_to do |format|
-
format.html
-
format.turbo_stream
-
format.json do
-
render json: {
-
id: @user_resume.id,
-
analysis_status: @user_resume.analysis_status,
-
skills_count: @total_skills_count
-
}
-
end
-
end
-
end
-
-
# GET /resumes/new
-
#
-
# Upload form for new resume
-
def new
-
@user_resume = Current.user.user_resumes.build
-
@job_roles = JobRole.order(:title)
-
@companies = Company.order(:name)
-
end
-
-
# POST /resumes
-
#
-
# Create a new resume and enqueue analysis
-
def create
-
@user_resume = Current.user.user_resumes.build(user_resume_params)
-
-
if @user_resume.save
-
maybe_unlock_insight_trial_after_cv_upload
-
# Always redirect to show page to see processing status
-
redirect_to user_resume_path(@user_resume), notice: "Resume uploaded! Analysis in progress..."
-
else
-
@job_roles = JobRole.order(:title)
-
@companies = Company.order(:name)
-
render :new, status: :unprocessable_entity
-
end
-
end
-
-
# GET /resumes/:id/edit
-
#
-
# Edit resume metadata (name, purpose, target role/company)
-
def edit
-
@job_roles = JobRole.order(:title)
-
@companies = Company.order(:name)
-
end
-
-
# PATCH/PUT /resumes/:id
-
#
-
# Update resume metadata
-
def update
-
respond_to do |format|
-
if @user_resume.update(user_resume_params)
-
format.html { redirect_to user_resume_path(@user_resume), notice: "Resume updated." }
-
format.turbo_stream do
-
render turbo_stream: [
-
turbo_stream.replace("resume_card_#{@user_resume.id}", partial: "user_resumes/resume_card", locals: { user_resume: @user_resume }),
-
turbo_stream.update("flash", partial: "shared/flash", locals: { flash: { notice: "Resume updated." } })
-
]
-
end
-
else
-
format.html do
-
@job_roles = JobRole.order(:title)
-
@companies = Company.order(:name)
-
render :edit, status: :unprocessable_entity
-
end
-
format.turbo_stream do
-
render turbo_stream: turbo_stream.update(
-
"flash",
-
partial: "shared/flash",
-
locals: { flash: { alert: @user_resume.errors.full_messages.join(", ") } }
-
)
-
end
-
end
-
end
-
end
-
-
# DELETE /resumes/:id
-
#
-
# Delete resume and trigger skill re-aggregation
-
def destroy
-
@user_resume.destroy!
-
-
# Re-aggregate skills for the user
-
Resumes::SkillAggregationService.new(Current.user).aggregate_all
-
-
respond_to do |format|
-
format.html { redirect_to user_resumes_path, notice: "Resume deleted." }
-
format.turbo_stream do
-
@user_skills = Current.user.user_skills.includes(:skill_tag).by_level_desc
-
@merged_strengths = merged_strengths_for(Current.user)
-
@resume_domains = aggregated_label_counts(Current.user.user_resumes.analyzed.pluck(:domains).flatten)
-
render turbo_stream: [
-
turbo_stream.remove("resume_card_#{params[:id]}"),
-
turbo_stream.update("skill_profile", partial: "user_resumes/skill_profile", locals: { user_skills: @user_skills, merged_strengths: @merged_strengths, domains: @resume_domains }),
-
turbo_stream.update("flash", partial: "shared/flash", locals: { flash: { notice: "Resume deleted." } })
-
]
-
end
-
end
-
end
-
-
# POST /resumes/:id/reanalyze
-
#
-
# Re-run AI analysis on existing resume
-
def reanalyze
-
if @user_resume.analysis_status_processing?
-
respond_to do |format|
-
format.html { redirect_to user_resume_path(@user_resume), alert: "Analysis already in progress." }
-
format.turbo_stream do
-
render turbo_stream: turbo_stream.update(
-
"flash",
-
partial: "shared/flash",
-
locals: { flash: { alert: "Analysis already in progress." } }
-
)
-
end
-
end
-
return
-
end
-
-
# Reset status and re-enqueue
-
@user_resume.update!(analysis_status: :pending)
-
AnalyzeResumeJob.perform_later(@user_resume)
-
-
respond_to do |format|
-
format.html { redirect_to user_resume_path(@user_resume), notice: "Re-analysis started..." }
-
format.turbo_stream do
-
render turbo_stream: [
-
turbo_stream.replace("resume_status_#{@user_resume.id}", partial: "user_resumes/analysis_status", locals: { user_resume: @user_resume }),
-
turbo_stream.update("flash", partial: "shared/flash", locals: { flash: { notice: "Re-analysis started..." } })
-
]
-
end
-
end
-
end
-
-
private
-
-
# Sets the resume for member actions
-
#
-
# @return [UserResume]
-
def set_user_resume
-
@user_resume = current_user_resumes.find(params[:id])
-
rescue ActiveRecord::RecordNotFound
-
respond_to do |format|
-
format.html { redirect_to user_resumes_path, alert: "Resume not found." }
-
format.turbo_stream do
-
render turbo_stream: turbo_stream.update(
-
"flash",
-
partial: "shared/flash",
-
locals: { flash: { alert: "Resume not found." } }
-
)
-
end
-
end
-
end
-
-
# Returns the current user's resumes
-
#
-
# @return [ActiveRecord::Relation]
-
def current_user_resumes
-
Current.user.user_resumes
-
end
-
-
# Strong parameters for resume creation/update
-
#
-
# @return [ActionController::Parameters]
-
def user_resume_params
-
params.require(:user_resume).permit(
-
:name,
-
:file,
-
:purpose,
-
target_job_role_ids: [],
-
target_company_ids: []
-
)
-
end
-
-
# Aggregates a list of labels into a normalized hash with counts.
-
#
-
# @param labels [Array<String>]
-
# @return [Hash{String => Hash}] e.g. { "system design" => { label: "System Design", count: 2 } }
-
def aggregated_label_counts(labels)
-
Labels::DedupeService
-
.new(labels, similarity_threshold: 0.82, overlap_threshold: 0.75)
-
.grouped_counts
-
end
-
-
def merged_strengths_for(user)
-
resume_counts = aggregated_label_counts(user.user_resumes.analyzed.pluck(:strengths).flatten)
-
-
feedback_strengths = ProfileInsightsService.new(user).generate_insights[:strengths] || []
-
feedback_counts = {}
-
feedback_strengths.each do |row|
-
name = row[:name] || row["name"]
-
count = row[:count] || row["count"] || 0
-
key = normalize_label_key(name)
-
next if key.blank?
-
-
feedback_counts[key] ||= { label: name.to_s.strip, count: 0 }
-
feedback_counts[key][:count] += count.to_i
-
end
-
-
keys = (resume_counts.keys + feedback_counts.keys).uniq
-
merged = keys.map do |key|
-
resume = resume_counts[key]
-
feedback = feedback_counts[key]
-
-
label = resume&.dig(:label).presence || feedback&.dig(:label).presence || key
-
resume_count = resume&.dig(:count).to_i
-
feedback_count = feedback&.dig(:count).to_i
-
sources = []
-
sources << "resume" if resume_count.positive?
-
sources << "feedback" if feedback_count.positive?
-
-
{
-
key: key,
-
label: label,
-
total_count: resume_count + feedback_count,
-
resume_count: resume_count,
-
feedback_count: feedback_count,
-
sources: sources
-
}
-
end
-
-
merged.sort_by { |h| -h[:total_count].to_i }
-
end
-
-
def normalize_label_key(label)
-
# Kept for backward compatibility (used by merged_strengths_for feedback keys).
-
ActiveSupport::Inflector.transliterate(label.to_s)
-
.downcase
-
.tr("&", "and")
-
.gsub(/[^a-z0-9\s]/, " ")
-
.gsub(/\s+/, " ")
-
.strip
-
end
-
-
# Unlocks the insight-triggered trial when the user uploads a CV and already has exactly one feedback entry.
-
# This supports the "CV uploaded + first feedback entry" trigger regardless of which happens first.
-
#
-
# @return [void]
-
def maybe_unlock_insight_trial_after_cv_upload
-
user = Current.user
-
return if user.nil?
-
-
feedback_count = InterviewFeedback
-
.joins(interview_round: { interview_application: :user })
-
.where(users: { id: user.id })
-
.count
-
return unless feedback_count == 1
-
-
Billing::TrialUnlockService.new(
-
user: user,
-
trigger: :cv_upload_with_first_feedback_present,
-
metadata: { feedback_count: feedback_count, user_resume_id: @user_resume.id }
-
).run
-
end
-
end
-
# frozen_string_literal: true
-
-
module Webhooks
-
# Receives LemonSqueezy webhooks.
-
#
-
# Stores events for idempotency and async processing, then returns 200 quickly.
-
class LemonSqueezyController < ApplicationController
-
allow_unauthenticated_access
-
skip_before_action :verify_authenticity_token
-
-
# POST /webhooks/lemon_squeezy
-
def create
-
raw_body = request.raw_post.to_s
-
signature = signature_header
-
-
unless valid_signature?(raw_body, signature)
-
Rails.logger.warn("[billing] Invalid LemonSqueezy webhook signature")
-
head :unauthorized
-
return
-
end
-
-
payload = JSON.parse(raw_body) rescue {}
-
idempotency_key = request.headers["X-Event-Id"].presence || Digest::SHA256.hexdigest(raw_body)
-
event_type = payload.dig("meta", "event_name") || payload["event_name"] || payload["type"]
-
-
event = Billing::WebhookEvent.find_or_create_by!(provider: "lemonsqueezy", idempotency_key: idempotency_key) do |we|
-
we.event_type = event_type
-
we.payload = payload
-
we.received_at = Time.current
-
end
-
-
Rails.logger.info("[billing] lemonsqueezy webhook received event_type=#{event_type} key=#{idempotency_key} status=#{event.status}")
-
Billing::ProcessWebhookEventJob.perform_later(event) if event.processed_at.blank? && event.status == "pending"
-
-
head :ok
-
end
-
-
private
-
-
def signature_header
-
request.headers["X-Signature"].presence ||
-
request.headers["X-LemonSqueezy-Signature"].presence ||
-
request.headers["X-LemonSqueezy-Signature".downcase].presence
-
end
-
-
def webhook_secret
-
Rails.application.credentials.dig(:lemonsqueezy, :webhook_secret) || ENV["LEMONSQUEEZY_WEBHOOK_SECRET"]
-
end
-
-
def valid_signature?(raw_body, signature)
-
secret = webhook_secret.to_s
-
return false if secret.blank?
-
return false if signature.blank?
-
-
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)
-
ActiveSupport::SecurityUtils.secure_compare(expected, signature.to_s)
-
rescue => e
-
ExceptionNotifier.notify(
-
e,
-
context: "payment",
-
severity: "warning",
-
tags: { provider: "lemonsqueezy", operation: "webhook_signature" },
-
error: "signature_verification_failed"
-
)
-
false
-
end
-
end
-
end
-
-
-
# frozen_string_literal: true
-
-
# Assistant domain root namespace.
-
#
-
# All assistant-related code (chat, tools, policies, execution audit) should live under `Assistant::*`
-
# and be placed in `app/domains/assistant/**`.
-
1
module Assistant
-
end
-
# frozen_string_literal: true
-
-
require "dry/schema"
-
-
module Assistant
-
module Contracts
-
# Runtime contracts for provider adapter outputs.
-
#
-
# These validate the adapter boundary so malformed provider responses become explicit errors
-
# (instead of leaking as nils/odd hashes into the rest of the assistant pipeline).
-
module ProviderResultContracts
-
Openai = Dry::Schema.Params do
-
required(:raw_response).value(:any)
-
required(:content).value(:string)
-
required(:tool_calls).array(:hash)
-
required(:response_id).filled(:string)
-
optional(:input_tokens).maybe(:integer)
-
optional(:output_tokens).maybe(:integer)
-
end
-
-
Anthropic = Dry::Schema.Params do
-
required(:raw_response).value(:any)
-
required(:content).value(:string)
-
required(:tool_calls).array(:hash)
-
required(:content_blocks).array(:hash)
-
required(:message_id).filled(:string)
-
optional(:input_tokens).maybe(:integer)
-
optional(:output_tokens).maybe(:integer)
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
require "dry/schema"
-
-
module Assistant
-
module Contracts
-
# Runtime contract for a normalized tool call produced by an LLM provider.
-
#
-
# Expected input (symbol or string keys):
-
# - tool_key: String
-
# - args: Hash
-
# - provider_name: String
-
# - provider_tool_call_id: String (OpenAI call_id / Anthropic tool_use_id)
-
class ToolCallContract
-
Schema = Dry::Schema.Params do
-
required(:tool_key).filled(:string)
-
required(:args).hash
-
required(:provider_name).filled(:string)
-
required(:provider_tool_call_id).filled(:string)
-
end
-
-
# @param tool_call [Hash]
-
# @return [Dry::Schema::Result]
-
def self.call(tool_call)
-
Schema.call(tool_call)
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
require "dry/schema"
-
-
module Assistant
-
module Contracts
-
# Runtime contract for a tool result that will be sent back to an LLM provider.
-
class ToolResultContract
-
Schema = Dry::Schema.Params do
-
required(:provider_tool_call_id).filled(:string)
-
required(:tool_key).filled(:string)
-
required(:success).filled(:bool)
-
optional(:data).value(:any)
-
optional(:error).maybe(:string)
-
end
-
-
# @param tool_result [Hash]
-
# @return [Dry::Schema::Result]
-
def self.call(tool_result)
-
Schema.call(tool_result)
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
# A message within a chat thread.
-
class ChatMessage < ApplicationRecord
-
self.table_name = "assistant_messages"
-
-
include Assistant::HasUuid
-
-
ROLES = %w[user assistant tool].freeze
-
-
belongs_to :thread,
-
class_name: "Assistant::ChatThread",
-
foreign_key: :thread_id,
-
inverse_of: :messages
-
-
validates :role, presence: true, inclusion: { in: ROLES }
-
validates :content, presence: true
-
-
scope :chronological, -> { order(created_at: :asc) }
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module Assistant
-
# A chat thread (conversation) for a single user.
-
1
class ChatThread < ApplicationRecord
-
1
self.table_name = "assistant_threads"
-
-
1
include Assistant::HasUuid
-
-
1
belongs_to :user
-
-
1
has_many :messages,
-
class_name: "Assistant::ChatMessage",
-
foreign_key: :thread_id,
-
dependent: :destroy,
-
inverse_of: :thread
-
-
1
has_many :turns,
-
class_name: "Assistant::Turn",
-
foreign_key: :thread_id,
-
dependent: :destroy,
-
inverse_of: :thread
-
-
1
has_many :tool_executions,
-
class_name: "Assistant::ToolExecution",
-
foreign_key: :thread_id,
-
dependent: :destroy,
-
inverse_of: :thread
-
-
1
has_one :summary,
-
class_name: "Assistant::Memory::ThreadSummary",
-
foreign_key: :thread_id,
-
dependent: :destroy,
-
inverse_of: :thread
-
-
1
scope :recent_first, -> { order(last_activity_at: :desc, updated_at: :desc) }
-
-
# Returns a display-friendly title for the thread.
-
# Uses the explicit title if set, otherwise derives from first user message.
-
#
-
# @return [String] the display title
-
1
def display_title
-
then: 0
else: 0
return title if title.present?
-
-
first_user_message = messages.where(role: "user").order(:created_at).first
-
then: 0
else: 0
then: 0
if first_user_message&.content.present?
-
first_user_message.content.truncate(50)
-
else: 0
else
-
"New conversation"
-
end
-
end
-
-
# Returns a short version of the display title for sidebar links.
-
#
-
# @return [String] truncated title
-
1
def short_title
-
display_title.truncate(35)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require "securerandom"
-
-
1
module Assistant
-
# Adds a stable UUID identifier intended for external references (URLs, logs, admin ops).
-
#
-
# This keeps the internal integer primary key for joins, while providing a safe identifier
-
# that can be shared in logs or used for idempotency keys.
-
1
module HasUuid
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
2
before_validation :ensure_uuid, on: :create
-
-
2
validates :uuid, presence: true, uniqueness: true
-
end
-
-
1
def to_param
-
uuid
-
end
-
-
1
private
-
-
1
def ensure_uuid
-
self.uuid ||= SecureRandom.uuid
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Memory
-
class MemoryProposal < ApplicationRecord
-
self.table_name = "assistant_memory_proposals"
-
-
include Assistant::HasUuid
-
-
STATUSES = %w[pending accepted rejected expired].freeze
-
-
belongs_to :thread, class_name: "Assistant::ChatThread"
-
belongs_to :user
-
belongs_to :llm_api_log, class_name: "Ai::LlmApiLog", optional: true
-
belongs_to :confirmed_by, class_name: "User", optional: true
-
-
validates :status, inclusion: { in: STATUSES }
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Memory
-
class ThreadSummary < ApplicationRecord
-
self.table_name = "assistant_thread_summaries"
-
-
include Assistant::HasUuid
-
-
belongs_to :thread, class_name: "Assistant::ChatThread"
-
belongs_to :last_summarized_message, class_name: "Assistant::ChatMessage", optional: true
-
belongs_to :llm_api_log, class_name: "Ai::LlmApiLog", optional: true
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Memory
-
class UserMemory < ApplicationRecord
-
self.table_name = "assistant_user_memories"
-
-
include Assistant::HasUuid
-
-
belongs_to :user
-
-
scope :active, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Ops
-
class Event < ApplicationRecord
-
self.table_name = "assistant_events"
-
-
include Assistant::HasUuid
-
-
SEVERITIES = %w[debug info warn error].freeze
-
-
belongs_to :thread, class_name: "Assistant::ChatThread"
-
-
validates :severity, inclusion: { in: SEVERITIES }
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
# Tool registry entry (admin-managed).
-
class Tool < ApplicationRecord
-
self.table_name = "assistant_tools"
-
-
RISK_LEVELS = %w[read_only write_low write_high].freeze
-
-
validates :tool_key, presence: true, uniqueness: true
-
validates :name, presence: true
-
validates :description, presence: true
-
validates :risk_level, presence: true, inclusion: { in: RISK_LEVELS }
-
validates :executor_class, presence: true
-
-
scope :enabled, -> { where(enabled: true) }
-
scope :by_key, -> { order(:tool_key) }
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module Assistant
-
# A single tool execution record (audit + observability).
-
1
class ToolExecution < ApplicationRecord
-
1
self.table_name = "assistant_tool_executions"
-
-
1
include Assistant::HasUuid
-
-
1
STATUSES = %w[proposed queued running success error cancelled].freeze
-
1
PROVIDERS = %w[openai anthropic ollama].freeze
-
-
1
belongs_to :thread,
-
class_name: "Assistant::ChatThread",
-
foreign_key: :thread_id,
-
inverse_of: :tool_executions
-
-
1
belongs_to :assistant_message,
-
class_name: "Assistant::ChatMessage",
-
inverse_of: false
-
-
1
belongs_to :approved_by,
-
class_name: "User",
-
optional: true
-
-
1
validates :tool_key, presence: true
-
1
validates :status, presence: true, inclusion: { in: STATUSES }
-
1
validates :trace_id, presence: true
-
1
validates :provider_name, inclusion: { in: PROVIDERS }, allow_blank: true
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
# A single assistant turn: user message -> assistant response (+ tools).
-
class Turn < ApplicationRecord
-
self.table_name = "assistant_turns"
-
-
include Assistant::HasUuid
-
-
STATUSES = %w[success error].freeze
-
PROVIDERS = %w[openai anthropic ollama].freeze
-
-
belongs_to :thread,
-
class_name: "Assistant::ChatThread",
-
foreign_key: :thread_id,
-
inverse_of: :turns
-
-
belongs_to :user_message,
-
class_name: "Assistant::ChatMessage"
-
-
belongs_to :assistant_message,
-
class_name: "Assistant::ChatMessage"
-
-
belongs_to :llm_api_log,
-
class_name: "Ai::LlmApiLog"
-
-
validates :trace_id, presence: true
-
validates :status, presence: true, inclusion: { in: STATUSES }
-
validates :provider_name, inclusion: { in: PROVIDERS }, allow_blank: true
-
-
def to_param
-
uuid
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
# Determines which tools may be proposed for a given request.
-
#
-
# For now this is a thin wrapper around the enabled tool registry.
-
# It will grow to support per-user disables, feature flags, etc.
-
class ToolPolicy
-
def initialize(user:, thread:, page_context: {})
-
@user = user
-
@thread = thread
-
@page_context = page_context.to_h.symbolize_keys
-
end
-
-
def allowed_tools
-
Assistant::Tool.enabled.by_key
-
end
-
-
private
-
-
attr_reader :user, :thread, :page_context
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Chat
-
module Components
-
class LlmResponder
-
def initialize(user:, trace_id:, question:, context:, allowed_tools:, thread: nil, media: nil)
-
@user = user
-
@trace_id = trace_id
-
@question = question.to_s
-
@context = context
-
@allowed_tools = allowed_tools
-
@thread = thread
-
@media = Array(media).compact
-
end
-
-
def call
-
system_prompt = build_system_prompt_with_context
-
provider_chain = LlmProviders::ProviderConfigHelper.all_providers
-
-
last_error = nil
-
last_failure = nil
-
provider_chain.each do |provider_name|
-
res = attempt_provider(provider_name: provider_name, system_prompt: system_prompt)
-
if res[:status] == "success"
-
return res
-
end
-
-
last_error = res[:error].presence || last_error
-
last_failure = res if res[:status] == "error"
-
end
-
-
build_fallback_error(
-
provider_chain: provider_chain,
-
system_prompt: system_prompt,
-
last_error: last_error,
-
last_failure: last_failure
-
)
-
end
-
-
private
-
-
attr_reader :user, :trace_id, :question, :context, :allowed_tools, :thread, :media
-
-
# Returns the system prompt for the assistant.
-
# Uses system_prompt column from DB if available, falls back to default.
-
# This follows the same pattern as extraction prompts.
-
#
-
# @return [String] System prompt for the LLM
-
def active_system_prompt
-
Ai::AssistantSystemPrompt.active_prompt&.system_prompt.presence ||
-
Ai::AssistantSystemPrompt.default_system_prompt
-
end
-
-
# Builds the system prompt with injected context
-
#
-
# The context includes:
-
# - User info (name, account age)
-
# - Career context (resume summary, work history, targets)
-
# - Skills summary
-
# - Pipeline status
-
# - Current page context
-
def build_system_prompt_with_context
-
base_prompt = active_system_prompt
-
-
context_section = <<~CONTEXT
-
-
---
-
-
USER CONTEXT:
-
#{format_context_for_prompt}
-
-
---
-
CONTEXT
-
-
"#{base_prompt}\n#{context_section}"
-
end
-
-
# Formats the context hash into a readable section for the system prompt
-
def format_context_for_prompt
-
sections = []
-
-
# User info
-
if context[:user].present?
-
sections << "User: #{context[:user][:name]}"
-
end
-
-
# Career context (tiered - most important for job search assistance)
-
if context[:career].present?
-
career = context[:career]
-
-
if career[:resume].present?
-
resume = career[:resume]
-
sections << "\nProfile Summary: #{resume[:profile_summary]}" if resume[:profile_summary].present?
-
sections << "Strengths: #{resume[:strengths].join(', ')}" if resume[:strengths].present?
-
sections << "Domains: #{resume[:domains].join(', ')}" if resume[:domains].present?
-
-
# Full resume text only included when page_context has include_full_resume or resume_id
-
if resume[:full_text].present?
-
sections << "\n--- Full Resume ---\n#{resume[:full_text]}\n--- End Resume ---"
-
end
-
end
-
-
if career[:work_history].present?
-
work_lines = career[:work_history].map do |exp|
-
status = exp[:current] ? "(Current)" : ""
-
dates = [ exp[:start_date], exp[:end_date] ].compact.join(" - ")
-
skills = exp[:skills].present? ? "Skills: #{exp[:skills].join(', ')}" : nil
-
highlights = exp[:highlights].present? ? "Highlights: #{exp[:highlights].join('; ')}" : nil
-
[ "• #{exp[:title]} at #{exp[:company]} #{status} #{dates}", skills, highlights ].compact.join("\n ")
-
end
-
sections << "\nWork History:\n#{work_lines.join("\n")}"
-
end
-
-
if career[:targets].present?
-
targets = career[:targets]
-
sections << "\nCareer Targets:" if targets.any?
-
sections << " Target Roles: #{targets[:roles].join(', ')}" if targets[:roles].present?
-
sections << " Target Companies: #{targets[:companies].join(', ')}" if targets[:companies].present?
-
sections << " Target Domains: #{targets[:domains].join(', ')}" if targets[:domains].present?
-
end
-
end
-
-
# Top skills
-
if context[:skills].present? && context[:skills][:top_skills].present?
-
skills = context[:skills][:top_skills].map { |s| s[:name] }.compact.first(10)
-
sections << "\nTop Skills: #{skills.join(', ')}" if skills.any?
-
end
-
-
# Pipeline status
-
if context[:pipeline].present?
-
pipeline = context[:pipeline]
-
sections << "\nPipeline: #{pipeline[:interview_applications_count]} applications"
-
if pipeline[:recent_interview_applications].present?
-
recent = pipeline[:recent_interview_applications].first(3).map do |app|
-
base = "#{app[:job_role]} at #{app[:company]} (#{app[:status]})"
-
# Include identifiers so the model can reliably call tools.
-
# Many tools accept application_uuid/application_id, and without these the model may hallucinate.
-
ids = []
-
ids << "id=#{app[:id]}" if app[:id].present?
-
ids << "uuid=#{app[:uuid]}" if app[:uuid].present?
-
ids.any? ? "#{base} [#{ids.join(' ')}]" : base
-
end
-
sections << "Recent: #{recent.join('; ')}"
-
end
-
end
-
-
# Page context
-
if context[:page].present? && context[:page].any?
-
page_info = context[:page].map { |k, v| "#{k}: #{v}" }.join(", ")
-
sections << "\nCurrent Page: #{page_info}"
-
end
-
-
sections.join("\n")
-
end
-
-
def attempt_provider(provider_name:, system_prompt:)
-
provider = provider_for(provider_name)
-
return { status: "skipped", error: nil } unless provider&.available?
-
-
router = Assistant::Providers::ProviderRouter.new(
-
thread: thread,
-
question: question,
-
system_prompt: system_prompt,
-
allowed_tools: allowed_tools,
-
media: media
-
)
-
-
logger = Ai::ApiLoggerService.new(
-
operation_type: :assistant_chat,
-
loggable: user,
-
provider: provider.provider_name,
-
model: provider.model_name,
-
llm_prompt: Ai::AssistantSystemPrompt.active_prompt
-
)
-
-
request_for_log = router.request_payload_for_log(provider: provider)
-
result = logger.record(prompt: request_for_log, content_size: request_for_log.bytesize) do
-
router.call(provider: provider)
-
end
-
-
if result[:error].present?
-
return {
-
status: "error",
-
error: result[:error],
-
error_type: result[:error_type],
-
provider: provider.provider_name,
-
model: provider.model_name,
-
llm_api_log_id: result[:llm_api_log_id]
-
}
-
end
-
-
build_success_response(provider: provider, result: result)
-
end
-
-
def build_success_response(provider:, result:)
-
tool_calls = normalize_and_validate_tool_calls(result[:tool_calls], provider: provider.provider_name)
-
-
answer, pending_tool_followup = finalize_answer(
-
answer: result[:content].to_s,
-
tool_calls: tool_calls
-
)
-
-
{
-
answer: answer,
-
tool_calls: tool_calls,
-
llm_api_log: Ai::LlmApiLog.find(result[:llm_api_log_id]),
-
latency_ms: result[:latency_ms],
-
status: "success",
-
metadata: {
-
trace_id: trace_id,
-
provider: provider.provider_name,
-
model: provider.model_name,
-
tool_calls: tool_calls,
-
provider_state: extract_provider_state(provider: provider.provider_name, result: result),
-
provider_content_blocks: (provider.provider_name.to_s.downcase == "anthropic" ? result[:content_blocks] : nil),
-
pending_tool_followup: pending_tool_followup
-
}.compact
-
}
-
end
-
-
def normalize_and_validate_tool_calls(raw_tool_calls, provider:)
-
calls = Array(raw_tool_calls).map { |tc| normalize_tool_call(tc, provider: provider) }.compact
-
-
calls.select do |tc|
-
contract = Assistant::Contracts::ToolCallContract.call(tc)
-
next true if contract.success?
-
-
Rails.logger.warn("[LlmResponder] Dropping invalid tool_call: errors=#{contract.errors.to_h.inspect} tool_call=#{tc.inspect}")
-
false
-
end
-
end
-
-
def finalize_answer(answer:, tool_calls:)
-
pending_tool_followup = pending_tool_followup?(tool_calls: tool_calls)
-
-
if pending_tool_followup
-
return [ "Working on it — I’m fetching the latest info now.", true ]
-
end
-
-
if answer.strip.blank? && tool_calls.any?
-
answer = "I have some proposed actions for you to review below."
-
end
-
-
answer = "I couldn't generate a response. Please try again." if answer.strip.blank?
-
-
[ answer, false ]
-
end
-
-
def pending_tool_followup?(tool_calls:)
-
return false if tool_calls.blank?
-
-
tool_calls.any? do |tc|
-
tool = allowed_tools.find { |t| t.tool_key == tc[:tool_key] }
-
next false if tool.nil?
-
-
(tool.requires_confirmation || tool.risk_level != "read_only") == false
-
end
-
end
-
-
def build_fallback_error(provider_chain:, system_prompt:, last_error:, last_failure:)
-
error_message = last_error || "All providers failed"
-
-
# Prefer the real provider attempt log (it has provider/model/error_type/raw_response)
-
# so the turn links to the useful failure details.
-
if last_failure&.dig(:llm_api_log_id).present?
-
log = Ai::LlmApiLog.find(last_failure[:llm_api_log_id])
-
return {
-
answer: "Sorry — I ran into an issue generating a response. Please try again.",
-
tool_calls: [],
-
llm_api_log: log,
-
latency_ms: nil,
-
status: "error",
-
metadata: {
-
trace_id: trace_id,
-
provider: last_failure[:provider] || log.provider || "unknown",
-
model: last_failure[:model] || log.model || "unknown",
-
error: error_message,
-
error_type: last_failure[:error_type] || log.error_type
-
}.compact
-
}
-
end
-
-
# No provider attempt log exists (e.g., all providers were unavailable). Record a synthetic log.
-
fallback_provider = provider_for(provider_chain.first)
-
logger = Ai::ApiLoggerService.new(
-
operation_type: :assistant_chat,
-
loggable: user,
-
provider: (fallback_provider&.provider_name || "unknown"),
-
model: (fallback_provider&.model_name || "unknown"),
-
llm_prompt: Ai::AssistantSystemPrompt.active_prompt
-
)
-
-
request_for_log = build_request_for_log(provider_name: (fallback_provider&.provider_name || "unknown"), system_prompt: system_prompt)
-
failed = logger.record(prompt: request_for_log, content_size: request_for_log.bytesize) do
-
{ content: nil, input_tokens: nil, output_tokens: nil, confidence: nil, error: error_message, error_type: "all_providers_failed" }
-
end
-
-
{
-
answer: "Sorry — I ran into an issue generating a response. Please try again.",
-
tool_calls: [],
-
llm_api_log: Ai::LlmApiLog.find(failed[:llm_api_log_id]),
-
latency_ms: failed[:latency_ms],
-
status: "error",
-
metadata: {
-
trace_id: trace_id,
-
provider: (fallback_provider&.provider_name || "unknown"),
-
model: (fallback_provider&.model_name || "unknown"),
-
error: error_message,
-
error_type: "all_providers_failed"
-
}.compact
-
}
-
end
-
-
def normalize_tool_call(tc, provider:)
-
h = tc.is_a?(Hash) ? tc : {}
-
tool_key = h[:tool_key] || h["tool_key"] || h[:name] || h["name"]
-
args = h[:args] || h["args"] || h[:input] || h["input"] || {}
-
tool_call_id = h[:id] || h["id"] || h[:call_id] || h["call_id"]
-
return nil if tool_key.blank?
-
-
{
-
tool_key: tool_key.to_s,
-
args: args.is_a?(Hash) ? args : {},
-
provider_name: provider.to_s,
-
provider_tool_call_id: tool_call_id.to_s.presence
-
}.compact
-
end
-
-
def extract_provider_state(provider:, result:)
-
case provider.to_s.downcase
-
when "openai"
-
{ response_id: result[:response_id] || result["response_id"] }.compact
-
when "anthropic"
-
{ message_id: result[:message_id] || result["message_id"] }.compact
-
else
-
{}
-
end
-
end
-
-
def build_request_for_log(provider_name:, system_prompt:)
-
# Used only for synthetic fallback logging paths. ProviderRouter is used for real provider attempts.
-
{
-
provider: provider_name,
-
system: system_prompt.to_s,
-
question: question.to_s,
-
tools_count: allowed_tools.size
-
}.to_json
-
end
-
-
def provider_for(provider_name)
-
case provider_name.to_s.downcase
-
when "openai" then LlmProviders::OpenaiProvider.new
-
when "anthropic" then LlmProviders::AnthropicProvider.new
-
when "ollama" then LlmProviders::OllamaProvider.new
-
else nil
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Chat
-
module Components
-
class PromptBuilder
-
MAX_HISTORY_MESSAGES = 20
-
-
def initialize(context:, question:, allowed_tools:, conversation_history: [])
-
@context = context
-
@question = question.to_s
-
@allowed_tools = allowed_tools
-
@conversation_history = Array(conversation_history)
-
end
-
-
def build
-
tool_list = allowed_tools.map do |t|
-
{
-
tool_key: t.tool_key,
-
description: t.description,
-
arg_schema: t.arg_schema,
-
requires_confirmation: t.requires_confirmation,
-
risk_level: t.risk_level
-
}
-
end
-
-
sections = []
-
-
sections << <<~SECTION
-
CONTEXT (JSON):
-
#{context.to_json}
-
SECTION
-
-
sections << <<~SECTION
-
AVAILABLE_TOOLS (JSON):
-
#{tool_list.to_json}
-
SECTION
-
-
if conversation_history.any?
-
sections << <<~SECTION
-
CONVERSATION_HISTORY:
-
#{format_conversation_history}
-
SECTION
-
end
-
-
sections << <<~SECTION
-
USER_QUESTION:
-
#{question}
-
SECTION
-
-
sections.join("\n")
-
end
-
-
private
-
-
attr_reader :context, :question, :allowed_tools, :conversation_history
-
-
def format_conversation_history
-
# Take the most recent messages, respecting the limit
-
recent_messages = conversation_history.last(MAX_HISTORY_MESSAGES)
-
-
recent_messages.map do |msg|
-
role = msg[:role] || msg["role"]
-
content = msg[:content] || msg["content"]
-
"[#{role.upcase}]: #{content}"
-
end.join("\n\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Chat
-
module Components
-
# Continues a tool-using turn by sending tool results back to the LLM using the
-
# provider-native protocol, then (optionally) executing additional read-only
-
# tools requested by the model up to a bounded iteration limit.
-
class ToolFollowupResponder
-
MAX_ITERATIONS = 3
-
-
def initialize(user:, thread:, originating_assistant_message:)
-
@user = user
-
@thread = thread
-
@originating_assistant_message = originating_assistant_message
-
end
-
-
# @return [Hash] { answer:, tool_executions: }
-
def call
-
turn = Assistant::Turn.find_by(thread: thread, assistant_message: originating_assistant_message)
-
provider_name = (turn&.provider_name || originating_assistant_message.metadata["provider"] || originating_assistant_message.metadata[:provider]).to_s
-
provider_state = turn&.provider_state || {}
-
-
allowed_tools = Assistant::ToolPolicy.new(user: user, thread: thread, page_context: {}).allowed_tools
-
-
tool_executions = Assistant::ToolExecution.where(thread: thread, assistant_message: originating_assistant_message).order(created_at: :asc)
-
results = tool_executions.map { |te| tool_result_for(te) }.compact
-
-
case provider_name.downcase
-
when "openai"
-
openai_followup(
-
turn: turn,
-
provider_state: provider_state,
-
allowed_tools: allowed_tools,
-
tool_results: results
-
)
-
when "anthropic"
-
anthropic_followup(
-
allowed_tools: allowed_tools,
-
tool_results: results
-
)
-
else
-
{ answer: "Sorry — tool follow-up is not supported for provider: #{provider_name}.", tool_executions: [] }
-
end
-
end
-
-
private
-
-
attr_reader :user, :thread, :originating_assistant_message
-
-
def openai_followup(turn:, provider_state:, allowed_tools:, tool_results:)
-
previous_response_id = provider_state["response_id"] || provider_state[:response_id]
-
if previous_response_id.blank?
-
return { answer: "Sorry — I couldn't continue the tool-assisted response (missing provider state).", tool_executions: [] }
-
end
-
-
provider = LlmProviders::OpenaiProvider.new
-
tools = Assistant::Tools::ToolSchemaAdapter.new(allowed_tools).for_openai
-
tool_outputs = tool_results.map { |tr| { call_id: tr[:provider_tool_call_id], output: tr.to_json } }
-
-
iterations = 0
-
created = []
-
-
loop do
-
iterations += 1
-
break if iterations > MAX_ITERATIONS
-
-
logger = Ai::ApiLoggerService.new(
-
operation_type: :assistant_tool_call,
-
loggable: user,
-
provider: provider.provider_name,
-
model: provider.model_name,
-
llm_prompt: Ai::AssistantSystemPrompt.active_prompt
-
)
-
-
result = logger.record(prompt: { previous_response_id: previous_response_id, tool_outputs: tool_outputs }.to_json) do
-
provider.run(
-
nil,
-
# Provide a minimal message list so OpenAI input is always valid.
-
messages: [ { role: "user", content: "" } ],
-
tools: tools,
-
previous_response_id: previous_response_id,
-
tool_outputs: tool_outputs,
-
temperature: 0.2,
-
max_tokens: 1200
-
)
-
end
-
-
break if result[:error].present?
-
-
if result[:response_id].present?
-
previous_response_id = result[:response_id]
-
persist_openai_response_id!(turn: turn, response_id: previous_response_id)
-
end
-
-
tool_calls = Array(result[:tool_calls])
-
if tool_calls.any? && iterations < MAX_ITERATIONS
-
created += create_and_execute_followup_tools(tool_calls, provider_name: provider.provider_name)
-
tool_outputs = created.last(tool_calls.length).map { |te|
-
{ call_id: te.provider_tool_call_id, output: tool_result_for(te).to_json }
-
}
-
next
-
end
-
-
answer = result[:content].to_s
-
answer = "Done." if answer.strip.blank?
-
return { answer: answer, tool_executions: created }
-
end
-
-
{ answer: "Sorry — I couldn’t finish the tool follow-up.", tool_executions: created }
-
end
-
-
def persist_openai_response_id!(turn:, response_id:)
-
return if turn.nil? || response_id.blank?
-
-
turn.update!(provider_name: "openai", provider_state: (turn.provider_state || {}).merge("response_id" => response_id, "awaiting_tool_outputs" => false))
-
originating_assistant_message.update!(
-
metadata: originating_assistant_message.metadata.merge(
-
"provider_state" => (originating_assistant_message.metadata["provider_state"] || {}).merge("response_id" => response_id),
-
"awaiting_tool_outputs" => false
-
)
-
)
-
rescue StandardError
-
# best-effort only
-
end
-
-
def anthropic_followup(allowed_tools:, tool_results:)
-
provider = LlmProviders::AnthropicProvider.new
-
system_prompt = Ai::AssistantSystemPrompt.active_prompt&.system_prompt.presence ||
-
Ai::AssistantSystemPrompt.default_system_prompt
-
tools = Assistant::Tools::ToolSchemaAdapter.new(allowed_tools).for_anthropic
-
-
messages = Assistant::Providers::Anthropic::MessageBuilder.new(
-
thread: thread,
-
question: "",
-
system_prompt: system_prompt,
-
allowed_tools: allowed_tools,
-
media: []
-
).build_history_messages(
-
exclude_tool_results_for_assistant_message_id: originating_assistant_message.id,
-
include_pending_assistant_message_id: originating_assistant_message.id
-
)
-
created = []
-
-
tool_result_blocks = tool_results.map do |tr|
-
{
-
type: "tool_result",
-
tool_use_id: tr[:provider_tool_call_id],
-
content: tr.to_json,
-
is_error: tr[:success] == false
-
}.compact
-
end
-
-
iterations = 0
-
loop do
-
iterations += 1
-
break if iterations > MAX_ITERATIONS
-
-
logger = Ai::ApiLoggerService.new(
-
operation_type: :assistant_tool_call,
-
loggable: user,
-
provider: provider.provider_name,
-
model: provider.model_name,
-
llm_prompt: Ai::AssistantSystemPrompt.active_prompt
-
)
-
-
# IMPORTANT: for Anthropic, once we send tool_result blocks to the API, we must also
-
# persist them into our in-memory `messages` history. Anthropic is stateless and enforces
-
# the adjacency rule for *every* prior tool_use block in history: tool_use must be
-
# immediately followed by a user message with tool_result.
-
#
-
# If we don't append tool_result messages into `messages`, then a subsequent iteration
-
# (where the model requests more tools) will include a prior tool_use assistant message
-
# without its immediately-following tool_result message, causing a 400.
-
call_messages = messages + [ { role: "user", content: tool_result_blocks } ]
-
-
result = logger.record(prompt: { messages_count: call_messages.length, tool_results_count: tool_result_blocks.length }.to_json) do
-
provider.run(
-
nil,
-
messages: call_messages,
-
tools: tools,
-
system_message: system_prompt,
-
temperature: 0.2,
-
max_tokens: 1200
-
)
-
end
-
-
break if result[:error].present?
-
-
# Persist the tool_result user message into history (see comment above).
-
messages = call_messages
-
-
# Add the full assistant blocks back to history so tool_result blocks have context.
-
if result[:content_blocks].present?
-
messages << { role: "assistant", content: result[:content_blocks] }
-
else
-
messages << { role: "assistant", content: result[:content].to_s }
-
end
-
-
tool_calls = Array(result[:tool_calls])
-
if tool_calls.any? && iterations < MAX_ITERATIONS
-
created += create_and_execute_followup_tools(tool_calls, provider_name: provider.provider_name)
-
tool_result_blocks = created.last(tool_calls.length).map { |te|
-
tr = tool_result_for(te)
-
{
-
type: "tool_result",
-
tool_use_id: te.provider_tool_call_id,
-
content: tr.to_json,
-
is_error: tr[:success] == false
-
}.compact
-
}
-
next
-
end
-
-
answer = result[:content].to_s
-
answer = "Done." if answer.strip.blank?
-
return { answer: answer, tool_executions: created }
-
end
-
-
{ answer: "Sorry — I couldn’t finish the tool follow-up.", tool_executions: created }
-
end
-
-
def create_and_execute_followup_tools(tool_calls, provider_name:)
-
created = []
-
-
Array(tool_calls).each do |tc|
-
tool_key = tc[:tool_key] || tc["tool_key"] || tc[:name] || tc["name"]
-
args = tc[:args] || tc["args"] || tc[:input] || tc["input"] || {}
-
provider_tool_call_id = tc[:id] || tc["id"] || tc[:call_id] || tc["call_id"]
-
-
tool = Assistant::Tool.find_by(tool_key: tool_key.to_s)
-
next unless tool&.enabled?
-
-
te = Assistant::ToolExecution.create!(
-
thread: thread,
-
assistant_message: originating_assistant_message,
-
tool_key: tool.tool_key,
-
args: args.is_a?(Hash) ? args : {},
-
status: "proposed",
-
trace_id: originating_assistant_message.metadata["trace_id"] || originating_assistant_message.metadata[:trace_id] || SecureRandom.uuid,
-
requires_confirmation: tool.requires_confirmation || tool.risk_level != "read_only",
-
idempotency_key: SecureRandom.uuid,
-
provider_name: provider_name,
-
provider_tool_call_id: provider_tool_call_id.to_s.presence
-
)
-
-
created << te
-
-
next if te.requires_confirmation
-
-
Assistant::Tools::Runner.new(user: user, tool_execution: te).call
-
end
-
-
created
-
end
-
-
def tool_result_for(tool_execution)
-
return nil if tool_execution.provider_tool_call_id.blank?
-
return nil unless tool_execution.status.in?(%w[success error])
-
-
result = {
-
provider_tool_call_id: tool_execution.provider_tool_call_id,
-
tool_key: tool_execution.tool_key,
-
success: tool_execution.status == "success",
-
data: tool_execution.result,
-
error: tool_execution.error
-
}.compact
-
-
contract = Assistant::Contracts::ToolResultContract.call(result)
-
if contract.success?
-
result
-
else
-
Rails.logger.warn("[ToolFollowupResponder] Invalid tool_result dropped: errors=#{contract.errors.to_h.inspect} tool_result=#{result.inspect}")
-
nil
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
require "securerandom"
-
require "digest"
-
-
module Assistant
-
module Chat
-
module Components
-
class ToolProposalRecorder
-
def initialize(trace_id:, assistant_message:, tool_calls:)
-
@trace_id = trace_id
-
@assistant_message = assistant_message
-
@tool_calls = tool_calls || []
-
end
-
-
def call
-
created = []
-
-
tool_calls.each do |tc|
-
tool = Assistant::Tool.find_by(tool_key: tc[:tool_key])
-
next unless tool&.enabled?
-
-
args = tc[:args] || {}
-
dedupe_key = Digest::SHA256.hexdigest([ tool.tool_key, args ].to_json)
-
existing = Assistant::ToolExecution.where(thread: assistant_message.thread, assistant_message: assistant_message, tool_key: tool.tool_key)
-
.where("metadata ->> 'dedupe_key' = ?", dedupe_key)
-
.exists?
-
next if existing
-
-
created << Assistant::ToolExecution.create!(
-
thread: assistant_message.thread,
-
assistant_message: assistant_message,
-
tool_key: tool.tool_key,
-
args: args,
-
status: "proposed",
-
trace_id: trace_id,
-
requires_confirmation: tool.requires_confirmation || tool.risk_level != "read_only",
-
idempotency_key: SecureRandom.uuid,
-
provider_name: tc[:provider_name] || tc["provider_name"],
-
provider_tool_call_id: tc[:provider_tool_call_id] || tc["provider_tool_call_id"],
-
metadata: { dedupe_key: dedupe_key }
-
)
-
end
-
-
created
-
end
-
-
private
-
-
attr_reader :trace_id, :assistant_message, :tool_calls
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
require "securerandom"
-
-
module Assistant
-
module Chat
-
# Orchestrates a single assistant turn:
-
# - persists user message
-
# - builds context snapshot
-
# - calls LLM (with fallback providers)
-
# - persists assistant message + turn record
-
# - records proposed tool executions (not executed here)
-
class Orchestrator
-
# @param user [User]
-
# @param thread [Assistant::ChatThread, nil]
-
# @param message [String]
-
# @param page_context [Hash]
-
# @param client_request_uuid [String, nil]
-
# @param media [Array<Hash>, nil] Optional media attachments
-
def initialize(user:, thread: nil, message:, page_context: {}, client_request_uuid: nil, media: nil)
-
@user = user
-
@thread = thread
-
@message = message.to_s
-
@page_context = page_context.to_h
-
@client_request_uuid = client_request_uuid.presence
-
@media = Array(media).compact
-
end
-
-
def call
-
raise ArgumentError, "message is blank" if message.strip.blank?
-
-
ensure_thread!
-
trace_id = SecureRandom.uuid
-
-
# Store media metadata in message for replay/debugging
-
msg_metadata = { trace_id: trace_id, page_context: page_context }
-
msg_metadata[:has_media] = true if media.present?
-
msg_metadata[:media_types] = media.map { |m| m[:media_type] }.compact if media.present?
-
-
user_msg = thread.messages.create!(
-
role: "user",
-
content: message,
-
metadata: msg_metadata
-
)
-
-
Assistant::Chat::TurnRunner.new(
-
user: user,
-
thread: thread,
-
user_message: user_msg,
-
trace_id: trace_id,
-
client_request_uuid: client_request_uuid,
-
page_context: page_context,
-
media: media
-
).call
-
end
-
-
private
-
-
attr_reader :user, :thread, :message, :page_context, :client_request_uuid, :media
-
-
def ensure_thread!
-
@thread ||= Assistant::ChatThread.create!(user: user, title: nil, last_activity_at: Time.current, status: "open")
-
end
-
-
# LLM/tool proposal logic extracted into Assistant::Chat::Components::*
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Chat
-
# Persists a tool execution outcome as a canonical tool-result chat message.
-
#
-
# This is used to build provider-native histories reliably (especially for Anthropic,
-
# which requires tool_use blocks to be followed by tool_result blocks).
-
class ToolResultMessagePersister
-
# @param tool_execution [Assistant::ToolExecution]
-
def initialize(tool_execution:)
-
@tool_execution = tool_execution
-
end
-
-
# @return [Assistant::ChatMessage, nil]
-
def call
-
return nil if tool_execution.nil?
-
return nil unless tool_execution.status.in?(%w[success error])
-
-
msg = find_or_initialize_message
-
-
msg.role = "tool"
-
msg.content = build_content
-
msg.metadata = build_metadata
-
msg.save!
-
-
msg
-
rescue StandardError
-
nil
-
end
-
-
private
-
-
attr_reader :tool_execution
-
-
def find_or_initialize_message
-
Assistant::ChatMessage
-
.where(thread: tool_execution.thread, role: "tool")
-
.where("metadata ->> 'tool_execution_id' = ?", tool_execution.id.to_s)
-
.first || tool_execution.thread.messages.build(role: "tool")
-
end
-
-
def build_content
-
status = tool_execution.status == "success" ? "success" : "error"
-
"Tool result (#{tool_execution.tool_key}): #{status}"
-
end
-
-
def build_metadata
-
{
-
tool_execution_id: tool_execution.id,
-
trace_id: tool_execution.trace_id,
-
provider_name: tool_execution.provider_name,
-
provider_tool_call_id: tool_execution.provider_tool_call_id,
-
tool_key: tool_execution.tool_key,
-
success: tool_execution.status == "success",
-
data: tool_execution.result,
-
error: tool_execution.error,
-
originating_assistant_message_id: tool_execution.assistant_message_id
-
}.compact
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Chat
-
# Runs a single assistant turn given an already-persisted user message.
-
#
-
# Owns the core workflow:
-
# - build context snapshot + allowed tools
-
# - call LLM (with provider fallback)
-
# - persist assistant message + Assistant::Turn
-
# - record proposed tool executions
-
# - enqueue read-only tool executions and background jobs
-
#
-
# This centralizes logic so controllers/jobs do not duplicate the LLM/tool flow.
-
class TurnRunner
-
# @param user [User]
-
# @param thread [Assistant::ChatThread]
-
# @param user_message [Assistant::ChatMessage] must be role="user"
-
# @param trace_id [String]
-
# @param client_request_uuid [String, nil]
-
# @param page_context [Hash]
-
# @param media [Array<Hash>, nil] Optional media attachments (images, documents)
-
def initialize(user:, thread:, user_message:, trace_id:, client_request_uuid: nil, page_context: {}, media: nil)
-
@user = user
-
@thread = thread
-
@user_message = user_message
-
@trace_id = trace_id.to_s
-
@client_request_uuid = client_request_uuid.presence
-
@page_context = page_context.to_h
-
@media = Array(media).compact
-
end
-
-
# @return [Hash] { thread:, user_message:, assistant_message:, turn:, trace_id:, tool_calls:, tool_executions: }
-
def call
-
validate_inputs!
-
-
existing = find_existing_turn
-
return existing if existing
-
-
result = run_transaction!
-
-
enqueue_background_jobs(result)
-
enqueue_auto_tools(result)
-
-
result
-
end
-
-
private
-
-
attr_reader :user, :thread, :user_message, :trace_id, :client_request_uuid, :page_context, :media
-
-
def validate_inputs!
-
raise ArgumentError, "thread is required" if thread.nil?
-
raise ArgumentError, "user is required" if user.nil?
-
raise ArgumentError, "user_message is required" if user_message.nil?
-
raise ArgumentError, "trace_id is required" if trace_id.blank?
-
end
-
-
def find_existing_turn
-
return nil if client_request_uuid.blank?
-
-
existing = Assistant::Turn.where(thread: thread, client_request_uuid: client_request_uuid).first
-
return nil unless existing
-
-
{
-
thread: thread,
-
user_message: existing.user_message,
-
assistant_message: existing.assistant_message,
-
turn: existing,
-
trace_id: existing.trace_id,
-
tool_calls: Array(existing.assistant_message&.metadata&.dig("tool_calls") || existing.assistant_message&.metadata&.dig(:tool_calls)),
-
tool_executions: Assistant::ToolExecution.where(thread: thread, assistant_message_id: existing.assistant_message_id).order(created_at: :asc)
-
}
-
end
-
-
def run_transaction!
-
ActiveRecord::Base.transaction do
-
context = Assistant::Context::Builder.new(user: user, page_context: page_context).build
-
allowed_tools = Assistant::ToolPolicy.new(user: user, thread: thread, page_context: page_context).allowed_tools
-
-
llm_result = Assistant::Chat::Components::LlmResponder.new(
-
user: user,
-
trace_id: trace_id,
-
question: user_message.content,
-
context: context,
-
allowed_tools: allowed_tools,
-
thread: thread,
-
media: media
-
).call
-
-
assistant_message = persist_assistant_message!(llm_result)
-
turn = persist_turn!(assistant_message: assistant_message, context: context, llm_result: llm_result)
-
tool_executions = persist_tool_executions!(assistant_message: assistant_message, llm_result: llm_result)
-
-
{
-
thread: thread,
-
user_message: user_message,
-
assistant_message: assistant_message,
-
turn: turn,
-
trace_id: trace_id,
-
tool_calls: llm_result[:tool_calls] || [],
-
tool_executions: tool_executions
-
}
-
end
-
end
-
-
def persist_assistant_message!(llm_result)
-
thread.messages.create!(
-
role: "assistant",
-
content: llm_result.fetch(:answer),
-
metadata: llm_result.fetch(:metadata).merge(trace_id: trace_id)
-
).tap do
-
thread.update!(last_activity_at: Time.current) if thread.last_activity_at.nil? || thread.last_activity_at < Time.current
-
end
-
end
-
-
def persist_turn!(assistant_message:, context:, llm_result:)
-
provider_name = llm_result.dig(:metadata, :provider).to_s
-
provider_state = (llm_result.dig(:metadata, :provider_state) || {}).dup
-
-
# For OpenAI, any emitted tool call puts the response into an "awaiting tool outputs" state.
-
# Until tool outputs are sent back (follow-up), we must not continue the conversation using
-
# previous_response_id, otherwise OpenAI will 400 ("No tool output found for function call ...").
-
if provider_name == "openai" && Array(llm_result[:tool_calls]).any?
-
provider_state["awaiting_tool_outputs"] = true
-
end
-
-
Assistant::Turn.create!(
-
thread: thread,
-
user_message: user_message,
-
assistant_message: assistant_message,
-
trace_id: trace_id,
-
context_snapshot: context,
-
llm_api_log: llm_result.fetch(:llm_api_log),
-
latency_ms: llm_result[:latency_ms],
-
status: llm_result[:status] || "success",
-
client_request_uuid: client_request_uuid,
-
provider_name: provider_name.presence,
-
provider_state: provider_state
-
)
-
end
-
-
def persist_tool_executions!(assistant_message:, llm_result:)
-
Assistant::Chat::Components::ToolProposalRecorder.new(
-
trace_id: trace_id,
-
assistant_message: assistant_message,
-
tool_calls: llm_result[:tool_calls] || []
-
).call
-
end
-
-
def enqueue_background_jobs(result)
-
AssistantThreadSummarizerJob.perform_later(result[:thread].id)
-
AssistantMemoryProposerJob.perform_later(user.id, result[:thread].id, result[:trace_id])
-
end
-
-
def enqueue_auto_tools(result)
-
Array(result[:tool_executions]).each do |tool_execution|
-
next if tool_execution.requires_confirmation
-
-
tool_execution.update!(status: "queued") if tool_execution.status == "proposed"
-
AssistantToolExecutionJob.perform_later(tool_execution.id)
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Context
-
# Builds a bounded context snapshot for the assistant.
-
#
-
# This should remain deterministic and small; it is persisted for observability.
-
#
-
# Context Strategy:
-
# - Always include: user summary, top skills, work history summary, target roles/domains
-
# - Include resume summary: by default (low token cost)
-
# - Include full resume text: only when page_context[:include_full_resume] is true
-
# (e.g., user is on resume page, or query explicitly needs raw resume content)
-
class Builder
-
# Maximum work experiences to include
-
MAX_WORK_EXPERIENCES = 5
-
# Maximum skills per work experience
-
MAX_SKILLS_PER_EXPERIENCE = 5
-
-
def initialize(user:, page_context: {})
-
@user = user
-
@page_context = page_context.to_h.symbolize_keys
-
end
-
-
def build
-
{
-
user: user_summary,
-
career: career_summary,
-
skills: skill_summary,
-
pipeline: pipeline_summary,
-
page: page_summary
-
}
-
end
-
-
private
-
-
attr_reader :user, :page_context
-
-
def user_summary
-
{
-
id: user.id,
-
name: user.display_name,
-
email_verified: user.email_verified?,
-
created_at: user.created_at&.iso8601
-
}
-
end
-
-
# Career context: resume summaries, work history, targets
-
# This provides rich context with minimal tokens (~300-800 tokens)
-
def career_summary
-
latest = latest_resume
-
extraction = resume_extraction(latest)
-
-
{
-
resume: resume_summary(latest, extraction),
-
work_history: work_history_summary,
-
targets: targets_summary
-
}.compact
-
end
-
-
def resume_summary(latest, extraction)
-
return nil if latest.nil?
-
-
summary = {
-
profile_summary: latest.analysis_summary,
-
strengths: Array(extraction["strengths"]).first(5),
-
domains: Array(extraction["domains"]).first(5),
-
resume_count: user.user_resumes.count,
-
latest_analyzed_at: latest.analyzed_at&.iso8601
-
}.compact
-
-
# Include full resume text only when explicitly requested
-
if include_full_resume?
-
summary[:full_text] = latest.parsed_text.to_s.truncate(10_000)
-
end
-
-
summary
-
end
-
-
def work_history_summary
-
experiences = user.user_work_experiences
-
.reverse_chronological
-
.includes(:skill_tags)
-
.limit(MAX_WORK_EXPERIENCES)
-
-
return nil if experiences.empty?
-
-
experiences.map do |exp|
-
{
-
title: exp.display_role_title,
-
company: exp.display_company_name,
-
current: exp.current,
-
start_date: exp.start_date&.strftime("%b %Y"),
-
end_date: exp.current ? "Present" : exp.end_date&.strftime("%b %Y"),
-
highlights: Array(exp.highlights).first(3),
-
skills: exp.skill_tags.pluck(:name).first(MAX_SKILLS_PER_EXPERIENCE)
-
}.compact
-
end
-
end
-
-
def targets_summary
-
target_roles = user.respond_to?(:target_job_roles) ? user.target_job_roles.pluck(:title).first(5) : []
-
target_companies = user.respond_to?(:target_companies) ? user.target_companies.pluck(:name).first(5) : []
-
target_domains = user.respond_to?(:target_domains) ? user.target_domains.pluck(:name).first(5) : []
-
-
targets = {
-
roles: target_roles.presence,
-
companies: target_companies.presence,
-
domains: target_domains.presence
-
}.compact
-
-
targets.presence
-
end
-
-
def skill_summary
-
top = user.respond_to?(:top_skills) ? user.top_skills(limit: 10) : []
-
-
{
-
top_skills: Array(top).map do |us|
-
{
-
name: us.try(:skill_tag)&.try(:name),
-
level: us.try(:level),
-
evidence: us.try(:evidence).presence
-
}.compact
-
end.compact
-
}
-
end
-
-
def pipeline_summary
-
apps = user.interview_applications.order(updated_at: :desc).limit(10)
-
{
-
interview_applications_count: user.interview_applications.count,
-
recent_interview_applications: apps.map do |a|
-
{
-
uuid: a.uuid,
-
id: a.id,
-
company: a.company&.name,
-
job_role: a.job_role&.title,
-
status: a.status,
-
updated_at: a.updated_at&.iso8601
-
}.compact
-
end
-
}
-
end
-
-
def page_summary
-
summary = {
-
job_listing_id: page_context[:job_listing_id],
-
interview_application_id: page_context[:interview_application_id],
-
interview_application_uuid: page_context[:interview_application_uuid],
-
opportunity_id: page_context[:opportunity_id],
-
resume_id: page_context[:resume_id]
-
}.compact
-
-
focused = focused_interview_application_summary
-
summary[:focused_interview_application] = focused if focused.present?
-
-
summary
-
end
-
-
# Helper methods
-
-
def latest_resume
-
@latest_resume ||= user.user_resumes
-
.analyzed
-
.recent_first
-
.first
-
end
-
-
def resume_extraction(resume)
-
return {} if resume.nil?
-
-
data = resume.extracted_data
-
data = JSON.parse(data) if data.is_a?(String)
-
data&.dig("resume_extraction", "parsed") || {}
-
rescue JSON::ParserError
-
{}
-
end
-
-
# Include full resume text when:
-
# 1. Explicitly requested via page_context
-
# 2. User is viewing a resume page (resume_id present)
-
def include_full_resume?
-
page_context[:include_full_resume] == true ||
-
page_context[:resume_id].present?
-
end
-
-
# @return [InterviewApplication, nil]
-
def focused_interview_application
-
uuid = page_context[:interview_application_uuid].to_s.presence
-
id = page_context[:interview_application_id]
-
-
if uuid.present?
-
return user.interview_applications.includes(:company, :job_role, :interview_rounds).find_by(uuid: uuid)
-
end
-
-
if id.present?
-
return user.interview_applications.includes(:company, :job_role, :interview_rounds).find_by(id: id)
-
end
-
-
nil
-
end
-
-
# @return [Hash, nil]
-
def focused_interview_application_summary
-
app = focused_interview_application
-
return nil if app.nil?
-
-
next_round = app.interview_rounds.upcoming.order(:scheduled_at).first
-
{
-
uuid: app.uuid,
-
id: app.id,
-
company: app.display_company&.name,
-
job_role: app.display_job_role&.title,
-
status: app.status,
-
pipeline_stage: app.pipeline_stage,
-
applied_at: app.applied_at&.iso8601,
-
notes_preview: app.notes.to_s.truncate(500),
-
next_interview: next_round ? {
-
id: next_round.id,
-
stage: next_round.stage,
-
stage_name: next_round.stage_display_name,
-
scheduled_at: next_round.scheduled_at,
-
interviewer: next_round.interviewer_display
-
}.compact : nil,
-
needs_scheduling: app.needs_scheduling?,
-
actionable_scheduling_link: app.actionable_scheduling_link
-
}.compact
-
rescue StandardError
-
nil
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Memory
-
# Proposes long-term memory items for user confirmation.
-
#
-
# Always user-confirmed: this service only creates MemoryProposal records.
-
class MemoryProposer
-
def initialize(user:, thread:, trace_id:)
-
@user = user
-
@thread = thread
-
@trace_id = trace_id
-
end
-
-
def propose!
-
return nil if recent_pending_proposal?
-
-
messages = thread.messages.order(created_at: :desc).limit(20).pluck(:role, :content).reverse
-
prompt = build_prompt(messages)
-
-
llm_log = run_llm(prompt)
-
return nil if llm_log.nil?
-
-
items = parse_items(llm_log.response_text)
-
return nil if items.empty?
-
-
Assistant::Memory::MemoryProposal.create!(
-
thread: thread,
-
user: user,
-
trace_id: trace_id,
-
proposed_items: items,
-
status: "pending",
-
llm_api_log: llm_log
-
)
-
end
-
-
private
-
-
attr_reader :user, :thread, :trace_id
-
-
def recent_pending_proposal?
-
Assistant::Memory::MemoryProposal.where(user: user, thread: thread, status: "pending").where("created_at > ?", 12.hours.ago).exists?
-
end
-
-
def build_prompt(messages)
-
prompt_template = Ai::AssistantMemoryProposalPrompt.active_prompt
-
template = prompt_template&.prompt_template || Ai::AssistantMemoryProposalPrompt.default_prompt_template
-
template.gsub("{{messages}}", messages.map { |r, c| "#{r.upcase}: #{c}" }.join("\n"))
-
end
-
-
def run_llm(prompt)
-
provider_chain = LlmProviders::ProviderConfigHelper.all_providers
-
-
provider_chain.each do |provider_name|
-
provider = provider_for(provider_name)
-
next unless provider&.available?
-
-
system_message = <<~SYS
-
Return only valid JSON. Do not include markdown or extra commentary.
-
SYS
-
-
logger = Ai::ApiLoggerService.new(
-
operation_type: :assistant_chat,
-
loggable: user,
-
provider: provider.provider_name,
-
model: provider.model_name,
-
llm_prompt: Ai::AssistantMemoryProposalPrompt.active_prompt
-
)
-
-
result = logger.record(prompt: prompt, content_size: prompt.bytesize) do
-
provider.run(prompt, system_message: system_message, temperature: 0.1, max_tokens: 800)
-
end
-
-
next if result[:error].present?
-
return Ai::LlmApiLog.find(result[:llm_api_log_id])
-
end
-
-
nil
-
end
-
-
def parse_items(text)
-
json = text.to_s.strip
-
match = json.match(/\{.*\}/m)
-
json = match[0] if match
-
data = JSON.parse(json)
-
items = Array(data["items"])
-
-
items.filter_map do |item|
-
next unless item.is_a?(Hash)
-
key = item["key"].to_s
-
next if key.blank?
-
{
-
"key" => key,
-
"value" => item["value"].is_a?(Hash) ? item["value"] : { "value" => item["value"] },
-
"reason" => item["reason"].to_s,
-
"confidence" => item["confidence"].to_f
-
}
-
end
-
rescue JSON::ParserError
-
[]
-
end
-
-
def provider_for(provider_name)
-
case provider_name.to_s.downcase
-
when "openai" then LlmProviders::OpenaiProvider.new
-
when "anthropic" then LlmProviders::AnthropicProvider.new
-
when "ollama" then LlmProviders::OllamaProvider.new
-
else nil
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Memory
-
# Produces/updates a rolling summary for a thread to bound prompt size.
-
class ThreadSummarizer
-
SUMMARY_EVERY_N_MESSAGES = 20
-
-
def initialize(thread:)
-
@thread = thread
-
end
-
-
def maybe_summarize!
-
summary = thread.summary || thread.build_summary
-
last_id = summary.last_summarized_message_id
-
-
scope = thread.messages.order(:id)
-
scope = scope.where("id > ?", last_id) if last_id.present?
-
new_count = scope.count
-
-
return nil if new_count < SUMMARY_EVERY_N_MESSAGES
-
-
messages = scope.limit(60).pluck(:role, :content)
-
prompt = build_prompt(existing_summary: summary.summary_text, messages: messages)
-
-
llm_log = run_llm(prompt)
-
return nil if llm_log.nil?
-
-
new_summary_text = parse_summary(llm_log.response_text)
-
-
summary.update!(
-
summary_text: new_summary_text,
-
summary_version: summary.summary_version.to_i + 1,
-
last_summarized_message_id: scope.maximum(:id),
-
llm_api_log: llm_log
-
)
-
-
summary
-
end
-
-
private
-
-
attr_reader :thread
-
-
def build_prompt(existing_summary:, messages:)
-
prompt_template = Ai::AssistantThreadSummaryPrompt.active_prompt
-
template = prompt_template&.prompt_template || Ai::AssistantThreadSummaryPrompt.default_prompt_template
-
-
template
-
.gsub("{{existing_summary}}", existing_summary.to_s)
-
.gsub("{{messages}}", messages.map { |r, c| "#{r.upcase}: #{c}" }.join("\n"))
-
end
-
-
def run_llm(prompt)
-
provider_chain = LlmProviders::ProviderConfigHelper.all_providers
-
-
provider_chain.each do |provider_name|
-
provider = provider_for(provider_name)
-
next unless provider&.available?
-
-
system_message = "Return only the updated summary text."
-
-
logger = Ai::ApiLoggerService.new(
-
operation_type: :assistant_chat,
-
loggable: thread,
-
provider: provider.provider_name,
-
model: provider.model_name,
-
llm_prompt: Ai::AssistantThreadSummaryPrompt.active_prompt
-
)
-
-
result = logger.record(prompt: prompt, content_size: prompt.bytesize) do
-
provider.run(prompt, system_message: system_message, temperature: 0.1, max_tokens: 600)
-
end
-
-
next if result[:error].present?
-
return Ai::LlmApiLog.find(result[:llm_api_log_id])
-
end
-
-
nil
-
end
-
-
def parse_summary(text)
-
text.to_s.strip
-
end
-
-
def provider_for(provider_name)
-
case provider_name.to_s.downcase
-
when "openai" then LlmProviders::OpenaiProvider.new
-
when "anthropic" then LlmProviders::AnthropicProvider.new
-
when "ollama" then LlmProviders::OllamaProvider.new
-
else nil
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Providers
-
module Anthropic
-
# Builds Anthropic Messages API request options for assistant chat and follow-ups.
-
#
-
# Key rule: every assistant tool_use block must be immediately followed by a user message
-
# with tool_result blocks for those tool_use ids.
-
class MessageBuilder
-
MAX_HISTORY_MESSAGES = 40
-
-
def initialize(thread:, question:, system_prompt:, allowed_tools:, media: [])
-
@thread = thread
-
@question = question.to_s
-
@system_prompt = system_prompt.to_s
-
@allowed_tools = Array(allowed_tools)
-
@media = Array(media).compact
-
end
-
-
# @return [Hash] options hash for LlmProviders::AnthropicProvider#run
-
def build_chat_options
-
tools = Assistant::Tools::ToolSchemaAdapter.new(allowed_tools).for_anthropic
-
messages = build_history_messages + [ { role: "user", content: question } ]
-
-
opts = {
-
messages: messages,
-
tools: tools,
-
system_message: system_prompt,
-
temperature: 0.2,
-
max_tokens: 1200
-
}
-
opts[:media] = media if media.present?
-
opts
-
end
-
-
# @return [Hash]
-
def build_log_payload
-
{
-
provider: "anthropic",
-
system: system_prompt.to_s,
-
messages_count: build_history_messages.length + 1,
-
tools_count: allowed_tools.size
-
}
-
end
-
-
# Builds conversational history including persisted tool results.
-
#
-
# @param exclude_tool_results_for_assistant_message_id [Integer, nil]
-
# @param include_pending_assistant_message_id [Integer, nil] include even if pending_tool_followup
-
# @return [Array<Hash>]
-
def build_history_messages(exclude_tool_results_for_assistant_message_id: nil, include_pending_assistant_message_id: nil)
-
return [] if thread.nil?
-
-
msgs = thread.messages.chronological.to_a.last(MAX_HISTORY_MESSAGES)
-
out = []
-
-
msgs.each do |m|
-
next unless m.role.in?(%w[user assistant])
-
is_included_pending = include_pending_assistant_message_id.to_i == m.id
-
next if !is_included_pending && m.role == "assistant" && (m.metadata["pending_tool_followup"] == true || m.metadata[:pending_tool_followup] == true)
-
next if m.role == "assistant" && (m.metadata["followup_for_assistant_message_id"].present? || m.metadata[:followup_for_assistant_message_id].present?)
-
-
if m.role == "assistant" && m.metadata["provider"] == "anthropic" && m.metadata["provider_content_blocks"].is_a?(Array)
-
blocks = Array(m.metadata["provider_content_blocks"])
-
out << { role: "assistant", content: blocks }
-
-
tool_use_ids = extract_tool_use_ids(blocks)
-
next if tool_use_ids.empty?
-
next if exclude_tool_results_for_assistant_message_id.to_i == m.id
-
-
tool_result_blocks = build_tool_result_blocks_for_assistant_message(m, tool_use_ids)
-
out << { role: "user", content: tool_result_blocks } if tool_result_blocks.any?
-
else
-
out << { role: m.role, content: m.content.to_s }
-
end
-
end
-
-
out
-
end
-
-
private
-
-
attr_reader :thread, :question, :system_prompt, :allowed_tools, :media
-
-
def extract_tool_use_ids(blocks)
-
Array(blocks).filter_map do |b|
-
next unless (b["type"] || b[:type]).to_s == "tool_use"
-
(b["id"] || b[:id]).to_s.presence
-
end
-
end
-
-
def build_tool_result_blocks_for_assistant_message(assistant_message, tool_use_ids)
-
tool_messages = Assistant::ChatMessage
-
.where(thread: assistant_message.thread, role: "tool")
-
.where("metadata ->> 'originating_assistant_message_id' = ?", assistant_message.id.to_s)
-
.where("metadata ->> 'provider_tool_call_id' IN (?)", tool_use_ids)
-
.order(created_at: :asc)
-
.to_a
-
-
by_call_id = tool_messages.index_by { |m| m.metadata["provider_tool_call_id"] || m.metadata[:provider_tool_call_id] }
-
-
tool_use_ids.map do |id|
-
tm = by_call_id[id]
-
payload =
-
if tm
-
meta = tm.metadata || {}
-
{
-
provider_tool_call_id: meta["provider_tool_call_id"] || meta[:provider_tool_call_id],
-
tool_key: meta["tool_key"] || meta[:tool_key] || "unknown",
-
success: meta["success"] == true || meta[:success] == true,
-
data: meta["data"] || meta[:data],
-
error: meta["error"] || meta[:error]
-
}.compact
-
else
-
fallback_tool_execution_payload(assistant_message, id)
-
end
-
-
{
-
type: "tool_result",
-
tool_use_id: id,
-
content: payload.to_json,
-
is_error: payload[:success] == false
-
}.compact
-
end
-
end
-
-
def fallback_tool_execution_payload(assistant_message, tool_use_id)
-
te = Assistant::ToolExecution.where(thread: assistant_message.thread, assistant_message: assistant_message, provider_tool_call_id: tool_use_id).order(created_at: :desc).first
-
if te && te.status.in?(%w[success error])
-
{
-
provider_tool_call_id: te.provider_tool_call_id,
-
tool_key: te.tool_key,
-
success: te.status == "success",
-
data: te.result,
-
error: te.error
-
}.compact
-
else
-
{ provider_tool_call_id: tool_use_id, tool_key: "unknown", success: false, error: "Tool result unavailable" }
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Providers
-
module Anthropic
-
# Normalizes Anthropic provider results into a stable internal shape.
-
class Parser
-
# @param provider_result [Hash]
-
# @return [Hash]
-
def self.normalize(provider_result)
-
h = provider_result.is_a?(Hash) ? provider_result : {}
-
{
-
content: h[:content] || h["content"],
-
tool_calls: h[:tool_calls] || h["tool_calls"] || [],
-
content_blocks: h[:content_blocks] || h["content_blocks"],
-
message_id: h[:message_id] || h["message_id"]
-
}.compact
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Providers
-
module Openai
-
# Builds OpenAI Responses API request options for assistant chat.
-
class MessageBuilder
-
MAX_HISTORY_MESSAGES = 20
-
-
def initialize(thread:, question:, system_prompt:, allowed_tools:, media: [])
-
@thread = thread
-
@question = question.to_s
-
@system_prompt = system_prompt.to_s
-
@allowed_tools = Array(allowed_tools)
-
@media = Array(media).compact
-
end
-
-
# @return [Hash] options hash for LlmProviders::OpenaiProvider#run
-
def build_chat_options
-
tools = Assistant::Tools::ToolSchemaAdapter.new(allowed_tools).for_openai
-
previous_response_id = last_openai_response_id
-
-
messages =
-
if previous_response_id.present?
-
[ { role: "user", content: question } ]
-
else
-
history = build_history_messages
-
[ { role: "system", content: system_prompt } ] + history + [ { role: "user", content: question } ]
-
end
-
-
opts = {
-
messages: messages,
-
tools: tools,
-
previous_response_id: previous_response_id,
-
temperature: 0.2,
-
max_tokens: 1200
-
}
-
opts[:media] = media if media.present?
-
opts
-
end
-
-
# @return [Hash]
-
def build_log_payload
-
{
-
provider: "openai",
-
system: system_prompt.to_s,
-
previous_response_id: last_openai_response_id,
-
messages: build_history_messages + [ { role: "user", content: question } ],
-
tools_count: allowed_tools.size
-
}
-
end
-
-
private
-
-
attr_reader :thread, :question, :system_prompt, :allowed_tools, :media
-
-
def build_history_messages
-
return [] if thread.nil?
-
-
msgs = thread.messages.chronological.to_a.last(MAX_HISTORY_MESSAGES)
-
msgs
-
.select { |m| m.role.in?(%w[user assistant]) }
-
.reject { |m| m.role == "assistant" && (m.metadata["pending_tool_followup"] == true || m.metadata[:pending_tool_followup] == true) }
-
.reject { |m| m.role == "assistant" && (m.metadata["followup_for_assistant_message_id"].present? || m.metadata[:followup_for_assistant_message_id].present?) }
-
.map { |m| { role: m.role, content: m.content.to_s } }
-
end
-
-
# Mirrors prior behavior in LlmResponder: reuse last response_id that is not awaiting tool outputs.
-
def last_openai_response_id
-
return nil if thread.nil?
-
-
turns = thread.turns.order(created_at: :desc).where(provider_name: "openai").limit(25)
-
eligible = turns.find do |t|
-
state = t.provider_state || {}
-
awaiting = state["awaiting_tool_outputs"]
-
awaiting = state[:awaiting_tool_outputs] if awaiting.nil?
-
awaiting != true
-
end
-
-
state = eligible&.provider_state || {}
-
state["response_id"] || state[:response_id]
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Providers
-
module Openai
-
# Normalizes OpenAI provider results into a stable internal shape.
-
class Parser
-
# @param provider_result [Hash]
-
# @return [Hash]
-
def self.normalize(provider_result)
-
h = provider_result.is_a?(Hash) ? provider_result : {}
-
{
-
content: h[:content] || h["content"],
-
tool_calls: h[:tool_calls] || h["tool_calls"] || [],
-
response_id: h[:response_id] || h["response_id"]
-
}.compact
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Providers
-
# ProviderRouter centralizes provider-specific request building for assistant chat/tool calling.
-
#
-
# This keeps provider branching out of chat orchestration components.
-
class ProviderRouter
-
# @param thread [Assistant::ChatThread, nil]
-
# @param question [String]
-
# @param system_prompt [String]
-
# @param allowed_tools [Array<Assistant::Tool>]
-
# @param media [Array<Hash>]
-
def initialize(thread:, question:, system_prompt:, allowed_tools:, media: [])
-
@thread = thread
-
@question = question.to_s
-
@system_prompt = system_prompt.to_s
-
@allowed_tools = Array(allowed_tools)
-
@media = Array(media).compact
-
end
-
-
# @param provider [Object] LlmProviders::*Provider instance
-
# @return [Hash] provider.run result
-
def call(provider:)
-
provider_name = provider.provider_name.to_s.downcase
-
-
case provider_name
-
when "openai"
-
options = Providers::Openai::MessageBuilder.new(
-
thread: thread,
-
question: question,
-
system_prompt: system_prompt,
-
allowed_tools: allowed_tools,
-
media: media
-
).build_chat_options
-
provider.run(nil, options)
-
when "anthropic"
-
options = Providers::Anthropic::MessageBuilder.new(
-
thread: thread,
-
question: question,
-
system_prompt: system_prompt,
-
allowed_tools: allowed_tools,
-
media: media
-
).build_chat_options
-
provider.run(nil, options)
-
else
-
# Legacy prompt-based providers (not a focus right now).
-
legacy_prompt = Assistant::Chat::Components::PromptBuilder.new(
-
context: {},
-
question: question,
-
allowed_tools: allowed_tools,
-
conversation_history: []
-
).build
-
provider.run(legacy_prompt, system_message: system_prompt, temperature: 0.2, max_tokens: 1200)
-
end
-
end
-
-
# @param provider [Object] LlmProviders::*Provider instance
-
# @return [String] JSON payload for logging
-
def request_payload_for_log(provider:)
-
provider_name = provider.provider_name.to_s.downcase
-
-
payload =
-
case provider_name
-
when "openai"
-
Providers::Openai::MessageBuilder.new(
-
thread: thread,
-
question: question,
-
system_prompt: system_prompt,
-
allowed_tools: allowed_tools,
-
media: media
-
).build_log_payload
-
when "anthropic"
-
Providers::Anthropic::MessageBuilder.new(
-
thread: thread,
-
question: question,
-
system_prompt: system_prompt,
-
allowed_tools: allowed_tools,
-
media: media
-
).build_log_payload
-
else
-
{ provider: provider.provider_name, system: system_prompt.to_s, question: question.to_s, tools_count: allowed_tools.size }
-
end
-
-
payload.to_json
-
end
-
-
private
-
-
attr_reader :thread, :question, :system_prompt, :allowed_tools, :media
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Minimal JSON-schema-like validator for tool args.
-
#
-
# Supported schema keys:
-
# - type: "object"
-
# - required: ["field1", ...]
-
# - properties: { "field" => { "type" => "string|integer|boolean|number|object|array" } }
-
#
-
# Intentionally minimal to avoid new dependencies.
-
class ArgSchemaValidator
-
def initialize(schema)
-
@schema = schema.is_a?(Hash) ? schema : {}
-
end
-
-
def validate(args)
-
args = args.is_a?(Hash) ? args : {}
-
return [] if schema.blank?
-
-
# Support top-level anyOf/oneOf (minimal): valid if any sub-schema validates.
-
branches = Array(schema["anyOf"] || schema[:anyOf] || schema["oneOf"] || schema[:oneOf])
-
if branches.any?
-
branch_errors = branches.map { |sub| self.class.new(sub).validate(args) }
-
return [] if branch_errors.any?(&:empty?)
-
# Fall through and report errors from the first branch + base schema requirements/types.
-
end
-
-
errors = []
-
errors.concat(validate_required(args))
-
errors.concat(validate_types(args))
-
errors
-
end
-
-
private
-
-
attr_reader :schema
-
-
def validate_required(args)
-
required = Array(schema["required"] || schema[:required])
-
required.filter_map do |key|
-
k = key.to_s
-
"#{k} is required" if args[k].nil? && args[key.to_sym].nil?
-
end
-
end
-
-
def validate_types(args)
-
props = schema["properties"] || schema[:properties]
-
return [] unless props.is_a?(Hash)
-
-
props.flat_map do |k, v|
-
expected = (v.is_a?(Hash) ? (v["type"] || v[:type]) : nil)
-
next [] if expected.blank?
-
-
val = args[k.to_s]
-
val = args[k.to_sym] if val.nil?
-
next [] if val.nil?
-
-
ok = case expected.to_s
-
when "string" then val.is_a?(String)
-
when "integer" then val.is_a?(Integer)
-
when "number" then val.is_a?(Numeric)
-
when "boolean" then val == true || val == false
-
when "object" then val.is_a?(Hash)
-
when "array" then val.is_a?(Array)
-
else true
-
end
-
-
ok ? [] : [ "#{k} must be a #{expected}" ]
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
require "timeout"
-
-
module Assistant
-
module Tools
-
# Executes a proposed tool execution with guardrails:
-
# - tool exists/enabled
-
# - schema validation
-
# - confirmation required for write tools
-
# - idempotency
-
# - timeout
-
# - structured results + audit record updates
-
class Runner
-
def initialize(user:, tool_execution:, approved_by: nil)
-
@user = user
-
@tool_execution = tool_execution
-
@approved_by = approved_by
-
end
-
-
def call
-
return already_done if tool_execution.status == "success"
-
-
return fail!("unauthorized", "Not allowed") unless tool_execution.thread.user_id == user.id
-
-
tool = Assistant::Tool.find_by(tool_key: tool_execution.tool_key)
-
return fail!("tool_not_found", "Tool not found") if tool.nil?
-
return fail!("tool_disabled", "Tool is disabled") unless tool.enabled?
-
-
if tool_execution.requires_confirmation && approved_by.nil?
-
return fail!("confirmation_required", "This tool requires confirmation")
-
end
-
-
errors = Assistant::Tools::ArgSchemaValidator.new(tool.arg_schema).validate(tool_execution.args)
-
return fail!("schema_invalid", errors.join(", ")) if errors.any?
-
-
tool_execution.update!(
-
status: "running",
-
started_at: Time.current,
-
approved_by: approved_by,
-
approved_at: approved_by.present? ? Time.current : nil
-
)
-
-
result = nil
-
# Some tools (e.g. LLM-backed generators) can take longer than 60s.
-
# Keep an upper bound to avoid indefinite hangs, but allow tools to opt into longer timeouts.
-
Timeout.timeout((tool.timeout_ms.to_i / 1000.0).clamp(0.1, 300.0)) do
-
result = execute_tool(tool, tool_execution.args)
-
end
-
-
tool_execution.update!(
-
status: (result[:success] ? "success" : "error"),
-
finished_at: Time.current,
-
result: result[:data] || {},
-
error: result[:error]
-
)
-
-
record_event("tool_executed", severity: result[:success] ? "info" : "error", payload: {
-
tool_key: tool.tool_key,
-
status: tool_execution.status,
-
error: tool_execution.error
-
}.compact)
-
-
result
-
rescue Timeout::Error
-
fail!("timeout", "Tool execution timed out")
-
rescue StandardError => e
-
fail!("exception", e.message)
-
end
-
-
private
-
-
attr_reader :user, :tool_execution, :approved_by
-
-
def already_done
-
{ success: true, data: tool_execution.result }
-
end
-
-
def fail!(code, message)
-
tool_execution.update!(
-
status: "error",
-
finished_at: Time.current,
-
error: message
-
) if tool_execution.persisted? && tool_execution.status != "success"
-
-
record_event("tool_denied", severity: "warn", payload: {
-
tool_key: tool_execution.tool_key,
-
reason: code,
-
message: message
-
})
-
-
{ success: false, error: message, error_type: code }
-
end
-
-
def execute_tool(tool, args)
-
klass = tool.executor_class.safe_constantize
-
return { success: false, error: "Invalid executor_class", error_type: "executor_missing" } if klass.nil?
-
-
executor = klass.new(user: user)
-
unless executor.respond_to?(:call)
-
return { success: false, error: "Executor does not implement #call", error_type: "executor_invalid" }
-
end
-
-
executor.call(args: args, tool_execution: tool_execution)
-
end
-
-
def record_event(event_type, severity:, payload:)
-
Assistant::Ops::Event.create!(
-
thread: tool_execution.thread,
-
trace_id: tool_execution.trace_id,
-
event_type: event_type,
-
severity: severity,
-
payload: payload
-
)
-
rescue StandardError
-
# Best-effort; don't fail tool execution because events can't be recorded.
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Builds provider-native tool schema payloads from Assistant::Tool records.
-
class ToolSchemaAdapter
-
def initialize(tools)
-
@tools = Array(tools)
-
end
-
-
# @return [Array<Hash>] OpenAI Responses API tools payload
-
def for_openai
-
tools.map do |tool|
-
schema = normalize_openai_json_schema(tool.arg_schema.presence || { type: "object", properties: {} })
-
schema = sanitize_openai_top_level_schema(schema)
-
{
-
type: "function",
-
# Responses API expects function tool definition fields at the top-level.
-
# (Chat Completions uses {function: {...}}; Responses uses {name:, description:, parameters:}).
-
name: tool.tool_key.to_s,
-
description: tool.description.to_s,
-
parameters: schema
-
}
-
end
-
end
-
-
# @return [Array<Hash>] Anthropic Messages API tools payload
-
def for_anthropic
-
tools.map do |tool|
-
{
-
name: tool.tool_key.to_s,
-
description: tool.description.to_s,
-
input_schema: tool.arg_schema.presence || { type: "object", properties: {} }
-
}
-
end
-
end
-
-
private
-
-
attr_reader :tools
-
-
# OpenAI validates JSON schema more strictly than our internal schema registry.
-
# In particular, `type: "array"` must include an `items` schema.
-
#
-
# @param schema [Hash]
-
# @return [Hash]
-
def normalize_openai_json_schema(schema)
-
return {} unless schema.is_a?(Hash)
-
-
normalized = schema.deep_dup
-
-
case normalized["type"] || normalized[:type]
-
when "array"
-
normalized["items"] ||= normalized[:items]
-
normalized[:items] ||= normalized["items"]
-
normalized["items"] ||= {}
-
normalized[:items] ||= {}
-
normalized["items"] = normalize_openai_json_schema(normalized["items"])
-
normalized[:items] = normalized["items"]
-
when "object"
-
props = normalized["properties"] || normalized[:properties]
-
if props.is_a?(Hash)
-
props.each do |k, v|
-
props[k] = normalize_openai_json_schema(v)
-
end
-
end
-
normalized["properties"] = props if props
-
normalized[:properties] = props if props
-
end
-
-
# Handle common combinators
-
%w[anyOf oneOf allOf].each do |key|
-
arr = normalized[key] || normalized[key.to_sym]
-
next unless arr.is_a?(Array)
-
normalized[key] = arr.map { |v| normalize_openai_json_schema(v) }
-
normalized[key.to_sym] = normalized[key]
-
end
-
-
if (items = normalized["items"] || normalized[:items]).is_a?(Hash)
-
normalized["items"] = normalize_openai_json_schema(items)
-
normalized[:items] = normalized["items"]
-
end
-
-
normalized
-
end
-
-
# OpenAI Responses API function parameters currently reject top-level schema combinators.
-
# Error example:
-
# "Invalid schema ... must have type 'object' and not have 'oneOf'/'anyOf'/'allOf'/'enum'/'not' at the top level."
-
#
-
# We keep our richer internal schemas (used for server-side validation), but when sending tool
-
# definitions to OpenAI we strip unsupported top-level keys. This prevents the entire request
-
# from failing while still allowing nested enums, etc.
-
#
-
# @param schema [Hash]
-
# @return [Hash]
-
def sanitize_openai_top_level_schema(schema)
-
return {} unless schema.is_a?(Hash)
-
-
normalized = schema.deep_dup
-
type = (normalized["type"] || normalized[:type]).to_s
-
normalized["type"] = type.presence || "object"
-
normalized[:type] = normalized["type"]
-
-
# OpenAI requires top-level to be an object schema.
-
unless normalized["type"] == "object"
-
normalized["type"] = "object"
-
normalized[:type] = "object"
-
end
-
-
%w[anyOf oneOf allOf not enum].each do |k|
-
normalized.delete(k)
-
normalized.delete(k.to_sym)
-
end
-
-
normalized
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Write: append or replace notes on an interview application.
-
class AddNoteToApplicationTool < BaseTool
-
def call(args:, tool_execution:)
-
application_uuid = (args["application_uuid"] || args[:application_uuid]).to_s
-
application_id = (args["application_id"] || args[:application_id]).to_i
-
note = (args["note"] || args[:note]).to_s
-
mode = (args["mode"] || args[:mode] || "append").to_s
-
-
if application_uuid.blank? && application_id.zero?
-
return { success: false, error: "application_uuid or application_id is required" }
-
end
-
return { success: false, error: "note is blank" } if note.strip.blank?
-
-
app =
-
if application_uuid.present?
-
user.interview_applications.find_by(uuid: application_uuid)
-
else
-
user.interview_applications.find_by(id: application_id)
-
end
-
return { success: false, error: "Interview application not found" } if app.nil?
-
-
new_notes =
-
if mode == "replace"
-
note
-
else
-
existing = app.notes.to_s
-
existing.blank? ? note : "#{existing}\n\n#{note}"
-
end
-
-
app.update!(notes: new_notes)
-
-
{ success: true, data: { application_uuid: app.uuid, application_id: app.id, notes_length: app.notes.to_s.length } }
-
rescue StandardError => e
-
{ success: false, error: e.message }
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Write: add a company to the user's target companies list.
-
#
-
# args:
-
# - company_id (optional)
-
# - company_name (optional, used to find-or-create)
-
# - priority (optional)
-
# - companies (optional, array of {company_id?, company_name?, priority?})
-
class AddTargetCompanyTool < BaseTool
-
def call(args:, tool_execution:)
-
if args["companies"].is_a?(Array) || args[:companies].is_a?(Array)
-
return add_many(args)
-
end
-
-
add_one(args)
-
rescue ActiveRecord::RecordInvalid => e
-
{ success: false, error: e.record.errors.full_messages.join(", ") }
-
rescue StandardError => e
-
{ success: false, error: e.message }
-
end
-
-
private
-
-
def add_one(args)
-
company = resolve_company(args)
-
return { success: false, error: "Company not found" } if company.nil?
-
-
utc = UserTargetCompany.find_or_initialize_by(user: user, company: company)
-
if (args["priority"] || args[:priority]).present?
-
utc.priority = (args["priority"] || args[:priority]).to_i
-
end
-
utc.save!
-
-
{
-
success: true,
-
data: {
-
company: { id: company.id, name: company.name },
-
target_company: { id: utc.id, priority: utc.priority }
-
}
-
}
-
end
-
-
def add_many(args)
-
items = args["companies"]
-
items = args[:companies] if items.nil?
-
items = Array(items)
-
-
results = items.map do |item|
-
item = item.is_a?(Hash) ? item : {}
-
r = add_one(item)
-
{
-
input: item,
-
success: r[:success] == true,
-
data: r[:data],
-
error: r[:error]
-
}.compact
-
rescue StandardError => e
-
{ input: item, success: false, error: e.message }
-
end
-
-
successes = results.count { |r| r[:success] == true }
-
failures = results.count { |r| r[:success] == false }
-
-
{
-
success: failures.zero?,
-
data: {
-
added_count: successes,
-
failed_count: failures,
-
results: results
-
}
-
}
-
end
-
-
def resolve_company(args)
-
company_id = (args["company_id"] || args[:company_id]).to_i
-
if company_id.positive?
-
found = Company.find_by(id: company_id)
-
return found if found
-
# If the model provided an ID that doesn't exist in our DB, fall back to name-based lookup/creation.
-
end
-
-
name = (args["company_name"] || args[:company_name]).to_s.strip
-
return nil if name.blank?
-
-
Company.where("lower(name) = ?", name.downcase).first || Company.create!(name: name)
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Write: add a domain to the user's target domains list.
-
#
-
# args:
-
# - domain_id (optional)
-
# - domain_name (optional, used to find-or-create)
-
# - priority (optional)
-
# - domains (optional, array of {domain_id?, domain_name?, priority?})
-
class AddTargetDomainTool < BaseTool
-
def call(args:, tool_execution:)
-
if args["domains"].is_a?(Array) || args[:domains].is_a?(Array)
-
return add_many(args)
-
end
-
-
add_one(args)
-
rescue ActiveRecord::RecordInvalid => e
-
{ success: false, error: e.record.errors.full_messages.join(", ") }
-
rescue StandardError => e
-
{ success: false, error: e.message }
-
end
-
-
private
-
-
def add_one(args)
-
domain = resolve_domain(args)
-
return { success: false, error: "Domain not found or could not be created" } if domain.nil?
-
-
utd = UserTargetDomain.find_or_initialize_by(user: user, domain: domain)
-
if (args["priority"] || args[:priority]).present?
-
utd.priority = (args["priority"] || args[:priority]).to_i
-
end
-
utd.save!
-
-
{
-
success: true,
-
data: {
-
domain: { id: domain.id, name: domain.name },
-
target_domain: { id: utd.id, priority: utd.priority }
-
}
-
}
-
end
-
-
def add_many(args)
-
items = args["domains"]
-
items = args[:domains] if items.nil?
-
items = Array(items)
-
-
results = items.map do |item|
-
item = item.is_a?(Hash) ? item : {}
-
r = add_one(item)
-
{
-
input: item,
-
success: r[:success] == true,
-
data: r[:data],
-
error: r[:error]
-
}.compact
-
rescue StandardError => e
-
{ input: item, success: false, error: e.message }
-
end
-
-
successes = results.count { |r| r[:success] == true }
-
failures = results.count { |r| r[:success] == false }
-
-
{
-
success: failures.zero?,
-
data: {
-
added_count: successes,
-
failed_count: failures,
-
results: results
-
}
-
}
-
end
-
-
def resolve_domain(args)
-
domain_id = (args["domain_id"] || args[:domain_id]).to_i
-
if domain_id.positive?
-
found = Domain.find_by(id: domain_id)
-
return found if found
-
# If the model provided an ID that doesn't exist in our DB, fall back to name-based lookup/creation.
-
end
-
-
name = (args["domain_name"] || args[:domain_name]).to_s.strip
-
return nil if name.blank?
-
-
Domain.where("lower(name) = ?", name.downcase).first || Domain.create!(name: name)
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Write: add a job role to the user's target job roles list.
-
#
-
# args:
-
# - job_role_id (optional)
-
# - job_role_title (optional, used to find-or-create)
-
# - priority (optional)
-
# - job_roles (optional, array of {job_role_id?, job_role_title?, priority?})
-
class AddTargetJobRoleTool < BaseTool
-
def call(args:, tool_execution:)
-
if args["job_roles"].is_a?(Array) || args[:job_roles].is_a?(Array)
-
return add_many(args)
-
end
-
-
add_one(args)
-
rescue ActiveRecord::RecordInvalid => e
-
{ success: false, error: e.record.errors.full_messages.join(", ") }
-
rescue StandardError => e
-
{ success: false, error: e.message }
-
end
-
-
private
-
-
def add_one(args)
-
role = resolve_job_role(args)
-
return { success: false, error: "Job role not found" } if role.nil?
-
-
utjr = UserTargetJobRole.find_or_initialize_by(user: user, job_role: role)
-
if (args["priority"] || args[:priority]).present?
-
utjr.priority = (args["priority"] || args[:priority]).to_i
-
end
-
utjr.save!
-
-
{
-
success: true,
-
data: {
-
job_role: { id: role.id, title: role.title },
-
target_job_role: { id: utjr.id, priority: utjr.priority }
-
}
-
}
-
end
-
-
def add_many(args)
-
items = args["job_roles"]
-
items = args[:job_roles] if items.nil?
-
items = Array(items)
-
-
results = items.map do |item|
-
item = item.is_a?(Hash) ? item : {}
-
r = add_one(item)
-
{
-
input: item,
-
success: r[:success] == true,
-
data: r[:data],
-
error: r[:error]
-
}.compact
-
rescue StandardError => e
-
{ input: item, success: false, error: e.message }
-
end
-
-
successes = results.count { |r| r[:success] == true }
-
failures = results.count { |r| r[:success] == false }
-
-
{
-
success: failures.zero?,
-
data: {
-
added_count: successes,
-
failed_count: failures,
-
results: results
-
}
-
}
-
end
-
-
def resolve_job_role(args)
-
job_role_id = (args["job_role_id"] || args[:job_role_id]).to_i
-
if job_role_id.positive?
-
found = JobRole.find_by(id: job_role_id)
-
return found if found
-
# If the model provided an ID that doesn't exist in our DB, fall back to title-based lookup/creation.
-
end
-
-
title = (args["job_role_title"] || args[:job_role_title]).to_s.strip
-
return nil if title.blank?
-
-
JobRole.where("lower(title) = ?", title.downcase).first || JobRole.create!(title: title)
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
class BaseTool
-
def initialize(user:)
-
@user = user
-
end
-
-
private
-
-
attr_reader :user
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Confirms a memory proposal and persists selected items into long-term memory.
-
class ConfirmUserMemoryTool < BaseTool
-
# args:
-
# - proposal_id: integer
-
# - accepted_keys: array[string]
-
def call(args:, tool_execution:)
-
proposal_id = args["proposal_id"] || args[:proposal_id]
-
accepted_keys = Array(args["accepted_keys"] || args[:accepted_keys]).map(&:to_s)
-
-
proposal = Assistant::Memory::MemoryProposal.find_by(id: proposal_id, user: user)
-
return { success: false, error: "Proposal not found" } if proposal.nil?
-
return { success: false, error: "Proposal is not pending" } unless proposal.status == "pending"
-
-
items = Array(proposal.proposed_items)
-
accepted = items.select { |i| accepted_keys.include?(i["key"].to_s) }
-
rejected = items.reject { |i| accepted_keys.include?(i["key"].to_s) }
-
-
ActiveRecord::Base.transaction do
-
accepted.each do |item|
-
key = item["key"].to_s
-
value = item["value"].is_a?(Hash) ? item["value"] : { "value" => item["value"] }
-
-
record = Assistant::Memory::UserMemory.find_or_initialize_by(user: user, key: key)
-
record.value = value
-
record.source = "user"
-
record.confidence = 1.0
-
record.last_confirmed_at = Time.current
-
record.save!
-
end
-
-
proposal.update!(
-
status: "accepted",
-
confirmed_at: Time.current,
-
confirmed_by: user
-
)
-
end
-
-
{
-
success: true,
-
data: {
-
accepted: accepted.map { |i| i["key"] },
-
rejected: rejected.map { |i| i["key"] }
-
}
-
}
-
rescue StandardError => e
-
{ success: false, error: e.message }
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Write: create/schedule an interview round on an application.
-
#
-
# Supports both future scheduling and retroactive entry:
-
# - scheduled_at: when it is/was scheduled
-
# - completed_at: when it happened (optional)
-
class CreateInterviewRoundTool < BaseTool
-
def call(args:, tool_execution:)
-
application_uuid = (args["application_uuid"] || args[:application_uuid]).to_s
-
return { success: false, error: "application_uuid is required" } if application_uuid.blank?
-
-
app = user.interview_applications.find_by(uuid: application_uuid)
-
return { success: false, error: "Interview application not found" } if app.nil?
-
-
stage = (args["stage"] || args[:stage] || "screening").to_s
-
result = (args["result"] || args[:result] || "pending").to_s
-
-
round = app.interview_rounds.build(
-
stage: stage,
-
result: result,
-
stage_name: (args["stage_name"] || args[:stage_name]),
-
interviewer_name: (args["interviewer_name"] || args[:interviewer_name]),
-
interviewer_role: (args["interviewer_role"] || args[:interviewer_role]),
-
duration_minutes: (args["duration_minutes"] || args[:duration_minutes]),
-
scheduled_at: parse_time(args["scheduled_at"] || args[:scheduled_at]),
-
completed_at: parse_time(args["completed_at"] || args[:completed_at]),
-
notes: (args["notes"] || args[:notes])
-
)
-
-
# Position = next available if not provided
-
round.position = (args["position"] || args[:position]).to_i if (args["position"] || args[:position]).present?
-
round.position ||= (app.interview_rounds.maximum(:position).to_i + 1)
-
-
round.save!
-
-
{
-
success: true,
-
data: {
-
interview_round: {
-
id: round.id,
-
stage: round.stage,
-
stage_name: round.stage_display_name,
-
result: round.result,
-
scheduled_at: round.scheduled_at,
-
completed_at: round.completed_at,
-
interviewer: round.interviewer_display,
-
duration_minutes: round.duration_minutes
-
},
-
interview_application: {
-
uuid: app.uuid,
-
id: app.id
-
}
-
}
-
}
-
rescue ActiveRecord::RecordInvalid => e
-
{ success: false, error: e.record.errors.full_messages.join(", ") }
-
rescue StandardError => e
-
{ success: false, error: e.message }
-
end
-
-
private
-
-
def parse_time(value)
-
return nil if value.blank?
-
Time.zone.parse(value.to_s)
-
rescue ArgumentError, TypeError
-
nil
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Write: generate interview prep artifacts for a specific application.
-
# Triggers background generation of match analysis, focus areas, strength positioning, and question framing.
-
class GenerateInterviewPrepTool < BaseTool
-
VALID_KINDS = InterviewPrepArtifact::KINDS.map(&:to_s).freeze
-
-
def call(args:, tool_execution:)
-
application_id = (args["application_id"] || args[:application_id]).to_i
-
kinds = extract_kinds(args)
-
-
if application_id.zero?
-
return { success: false, error: "application_id is required" }
-
end
-
-
application = user.interview_applications.find_by(id: application_id)
-
-
if application.nil?
-
return { success: false, error: "Interview application not found" }
-
end
-
-
if kinds.empty?
-
return { success: false, error: "At least one prep type must be specified. Valid types: #{VALID_KINDS.join(', ')}" }
-
end
-
-
# Generate artifacts synchronously (they have caching via inputs_digest)
-
results = generate_artifacts(application, kinds)
-
-
{
-
success: results[:failures].zero?,
-
data: {
-
application: {
-
id: application.id,
-
company: application.display_company&.name,
-
role: application.display_job_role&.title
-
},
-
generated: results[:generated],
-
cached: results[:cached],
-
failed: results[:failed_kinds],
-
results: results[:details]
-
}
-
}
-
rescue StandardError => e
-
{ success: false, error: e.message }
-
end
-
-
private
-
-
def extract_kinds(args)
-
# Allow specifying specific kinds or "all"
-
kinds_arg = args["kinds"] || args[:kinds]
-
-
if kinds_arg == "all" || kinds_arg.nil?
-
return VALID_KINDS
-
end
-
-
Array(kinds_arg).map(&:to_s).select { |k| VALID_KINDS.include?(k) }
-
end
-
-
def generate_artifacts(application, kinds)
-
generated = []
-
cached = []
-
failed_kinds = []
-
details = {}
-
-
kinds.each do |kind|
-
service = service_for_kind(kind)
-
next if service.nil?
-
-
artifact = service.new(user: user, interview_application: application).call
-
-
if artifact.computed?
-
if artifact.saved_change_to_computed_at?
-
generated << kind
-
details[kind] = { status: "generated", computed_at: artifact.computed_at&.iso8601 }
-
else
-
cached << kind
-
details[kind] = { status: "cached", computed_at: artifact.computed_at&.iso8601 }
-
end
-
else
-
failed_kinds << kind
-
details[kind] = { status: "failed", error: artifact.error_message }
-
end
-
rescue StandardError => e
-
failed_kinds << kind
-
details[kind] = { status: "failed", error: e.message }
-
end
-
-
{
-
generated: generated,
-
cached: cached,
-
failed_kinds: failed_kinds,
-
failures: failed_kinds.size,
-
details: details
-
}
-
end
-
-
def service_for_kind(kind)
-
case kind.to_sym
-
when :match_analysis
-
InterviewPrep::GenerateMatchAnalysisService
-
when :focus_areas
-
InterviewPrep::GenerateFocusAreasService
-
when :strength_positioning
-
InterviewPrep::GenerateStrengthPositioningService
-
when :question_framing
-
InterviewPrep::GenerateQuestionFramingService
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Read-only: fetch a single interview application for the current user.
-
class GetInterviewApplicationTool < BaseTool
-
def call(args:, tool_execution:)
-
uuid = (args["application_uuid"] || args[:application_uuid]).to_s
-
application_id = (args["application_id"] || args[:application_id]).to_i
-
-
if uuid.blank? && application_id.zero?
-
return { success: false, error: "application_uuid or application_id is required" }
-
end
-
-
app =
-
if uuid.present?
-
user.interview_applications.includes(:company, :job_role, interview_rounds: :interview_feedback).find_by(uuid: uuid)
-
else
-
user.interview_applications.includes(:company, :job_role, interview_rounds: :interview_feedback).find_by(id: application_id)
-
end
-
return { success: false, error: "Interview application not found" } if app.nil?
-
-
rounds = app.interview_rounds.ordered.map { |r|
-
{
-
id: r.id,
-
stage: r.stage,
-
stage_name: r.stage_display_name,
-
scheduled_at: r.scheduled_at,
-
completed_at: r.completed_at,
-
result: r.result,
-
interviewer: r.interviewer_display,
-
duration_minutes: r.duration_minutes,
-
has_feedback: r.interview_feedback.present?
-
}
-
}
-
-
{
-
success: true,
-
data: {
-
application: {
-
uuid: app.uuid,
-
id: app.id,
-
status: app.status,
-
pipeline_stage: app.pipeline_stage,
-
applied_at: app.applied_at,
-
company: app.display_company&.name,
-
job_role: app.display_job_role&.title,
-
notes: app.notes
-
},
-
interview_rounds: rounds
-
}
-
}
-
rescue StandardError => e
-
{ success: false, error: e.message }
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Read-only: fetch feedback for an interview round (if any).
-
class GetInterviewFeedbackTool < BaseTool
-
def call(args:, tool_execution:)
-
round_id = (args["interview_round_id"] || args[:interview_round_id]).to_i
-
return { success: false, error: "interview_round_id is required" } if round_id <= 0
-
-
round = InterviewRound.includes(:interview_feedback, :interview_application).find_by(id: round_id)
-
return { success: false, error: "Interview round not found" } if round.nil?
-
return { success: false, error: "Not authorized" } unless round.interview_application.user_id == user.id
-
-
fb = round.interview_feedback
-
return { success: true, data: { interview_round_id: round.id, interview_feedback: nil } } if fb.nil?
-
-
{
-
success: true,
-
data: {
-
interview_round_id: round.id,
-
interview_feedback: {
-
id: fb.id,
-
went_well: fb.went_well,
-
to_improve: fb.to_improve,
-
self_reflection: fb.self_reflection,
-
interviewer_notes: fb.interviewer_notes,
-
recommended_action: fb.recommended_action,
-
tags: fb.tag_list,
-
ai_summary: fb.ai_summary
-
}
-
}
-
}
-
rescue StandardError => e
-
{ success: false, error: e.message }
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Read-only: get interview prep artifacts for a specific application.
-
# Returns existing prep artifacts (match analysis, focus areas, strength positioning, question framing).
-
class GetInterviewPrepTool < BaseTool
-
def call(args:, tool_execution:)
-
application_id = (args["application_id"] || args[:application_id]).to_i
-
-
if application_id.zero?
-
return { success: false, error: "application_id is required" }
-
end
-
-
application = user.interview_applications.find_by(id: application_id)
-
-
if application.nil?
-
return { success: false, error: "Interview application not found" }
-
end
-
-
artifacts = application.interview_prep_artifacts.includes(:llm_api_log)
-
-
{
-
success: true,
-
data: {
-
application: {
-
id: application.id,
-
company: application.display_company&.name,
-
role: application.display_job_role&.title,
-
status: application.status,
-
pipeline_stage: application.pipeline_stage
-
},
-
prep_artifacts: format_artifacts(artifacts),
-
has_all_artifacts: artifacts.computed.count == InterviewPrepArtifact::KINDS.size
-
}
-
}
-
rescue StandardError => e
-
{ success: false, error: e.message }
-
end
-
-
private
-
-
def format_artifacts(artifacts)
-
result = {}
-
-
InterviewPrepArtifact::KINDS.each do |kind|
-
artifact = artifacts.find { |a| a.kind == kind.to_s }
-
-
if artifact.nil?
-
result[kind] = { status: "not_generated" }
-
else
-
result[kind] = format_artifact(artifact)
-
end
-
end
-
-
result
-
end
-
-
def format_artifact(artifact)
-
base = {
-
status: artifact.status,
-
computed_at: artifact.computed_at&.iso8601
-
}
-
-
if artifact.computed?
-
base[:content] = format_content(artifact.kind, artifact.content)
-
elsif artifact.failed?
-
base[:error] = artifact.error_message
-
end
-
-
base
-
end
-
-
def format_content(kind, content)
-
return {} unless content.is_a?(Hash)
-
-
case kind.to_sym
-
when :match_analysis
-
{
-
match_label: content["match_label"],
-
strong_in: content["strong_in"],
-
partial_in: content["partial_in"],
-
missing_or_risky: content["missing_or_risky"],
-
notes: content["notes"]
-
}.compact
-
when :focus_areas
-
{
-
areas: content["areas"],
-
notes: content["notes"]
-
}.compact
-
when :strength_positioning
-
{
-
strengths: content["strengths"],
-
positioning_tips: content["positioning_tips"],
-
notes: content["notes"]
-
}.compact
-
when :question_framing
-
{
-
questions: content["questions"],
-
frameworks: content["frameworks"],
-
notes: content["notes"]
-
}.compact
-
else
-
content
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Read-only: return the user's next upcoming interview round (across all applications).
-
class GetNextInterviewTool < BaseTool
-
def call(args:, tool_execution:)
-
round = user.interview_rounds
-
.includes(:interview_application)
-
.upcoming
-
.order(:scheduled_at)
-
.first
-
-
return { success: true, data: { next_interview: nil } } if round.nil?
-
-
app = round.interview_application
-
-
{
-
success: true,
-
data: {
-
next_interview: {
-
interview_round: {
-
id: round.id,
-
stage: round.stage,
-
stage_name: round.stage_display_name,
-
scheduled_at: round.scheduled_at,
-
interviewer: round.interviewer_display,
-
duration_minutes: round.duration_minutes
-
},
-
interview_application: {
-
uuid: app.uuid,
-
id: app.id,
-
company: app.display_company&.name,
-
job_role: app.display_job_role&.title
-
}
-
}
-
}
-
}
-
rescue StandardError => e
-
{ success: false, error: e.message }
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Read-only: returns a compact profile + pipeline summary for the current user.
-
# Includes career context (work history, resume summary, target domains).
-
# NOTE: Does not expose email_address to the LLM for privacy.
-
class GetProfileSummaryTool < BaseTool
-
def call(args:, tool_execution:)
-
skills_limit = (args["top_skills_limit"] || args[:top_skills_limit] || 10).to_i.clamp(1, 25)
-
work_history_limit = (args["work_history_limit"] || args[:work_history_limit] || 5).to_i.clamp(1, 10)
-
-
{
-
success: true,
-
data: {
-
user: build_user_data,
-
career: build_career_data(work_history_limit),
-
target_lists: build_target_lists,
-
pipeline: build_pipeline_data,
-
top_skills: build_top_skills(skills_limit)
-
}
-
}
-
rescue StandardError => e
-
{ success: false, error: e.message }
-
end
-
-
private
-
-
def build_user_data
-
{
-
id: user.id,
-
name: user.name,
-
years_of_experience: user.years_of_experience,
-
current_company: user.current_company&.name,
-
current_job_role: user.current_job_role&.title,
-
bio: user.bio.presence,
-
social_profiles: {
-
linkedin: user.linkedin_url.presence,
-
github: user.github_url.presence,
-
twitter: user.twitter_url.presence,
-
portfolio: user.portfolio_url.presence,
-
gitlab: user.gitlab_url.presence
-
}.compact
-
}.compact
-
end
-
-
def build_career_data(work_history_limit)
-
resume = user.user_resumes.analyzed.recent_first.first
-
-
{
-
resume_summary: resume&.analysis_summary,
-
strengths: Array(resume&.strengths).first(5),
-
domains: Array(resume&.domains).first(5),
-
work_history: build_work_history(work_history_limit)
-
}.compact
-
end
-
-
def build_work_history(limit)
-
user.user_work_experiences
-
.reverse_chronological
-
.limit(limit)
-
.map do |exp|
-
{
-
id: exp.id,
-
company: exp.display_company_name,
-
role: exp.display_role_title,
-
start_date: exp.start_date&.to_s,
-
end_date: exp.end_date&.to_s,
-
is_current: exp.current?,
-
highlights: Array(exp.highlights).first(3),
-
skills: exp.skill_tags.pluck(:name).first(5)
-
}.compact
-
end
-
end
-
-
def build_target_lists
-
{
-
companies_count: user.target_companies.count,
-
companies: user.target_companies.limit(5).pluck(:name),
-
job_roles_count: user.target_job_roles.count,
-
job_roles: user.target_job_roles.limit(5).pluck(:title),
-
domains_count: user.target_domains.count,
-
domains: user.target_domains.limit(5).pluck(:name)
-
}
-
end
-
-
def build_pipeline_data
-
{
-
active_applications_count: user.interview_applications.where(status: "active").count,
-
applications_by_stage: user.interview_applications.group(:pipeline_stage).count
-
}
-
end
-
-
def build_top_skills(limit)
-
user.top_skills(limit: limit).includes(:skill_tag).map do |us|
-
{
-
skill: us.skill_tag&.name,
-
aggregated_level: us.aggregated_level&.round(2),
-
category: us.category
-
}.compact
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Read-only: get detailed information about a specific skill.
-
class GetSkillDetailsTool < BaseTool
-
def call(args:, tool_execution:)
-
skill_id = (args["skill_id"] || args[:skill_id]).to_i
-
skill_name = (args["skill_name"] || args[:skill_name]).to_s.strip
-
-
user_skill = find_skill(skill_id, skill_name)
-
-
if user_skill.nil?
-
return { success: false, error: "Skill not found in your profile" }
-
end
-
-
{
-
success: true,
-
data: format_skill_detail(user_skill)
-
}
-
rescue StandardError => e
-
{ success: false, error: e.message }
-
end
-
-
private
-
-
def find_skill(skill_id, skill_name)
-
if skill_id.positive?
-
skill = user.user_skills.find_by(id: skill_id)
-
return skill if skill
-
end
-
-
return nil if skill_name.blank?
-
-
user.user_skills
-
.joins(:skill_tag)
-
.where("lower(skill_tags.name) = ?", skill_name.downcase)
-
.first
-
end
-
-
def format_skill_detail(user_skill)
-
{
-
id: user_skill.id,
-
skill: {
-
id: user_skill.skill_tag_id,
-
name: user_skill.skill_tag&.name
-
},
-
category: user_skill.category,
-
proficiency: {
-
level: user_skill.aggregated_level&.round(2),
-
label: user_skill.proficiency_label,
-
is_strong: user_skill.strong?,
-
is_developing: user_skill.developing?
-
},
-
evidence: {
-
resume_count: user_skill.resume_count,
-
confidence: user_skill.confidence_score&.round(2),
-
confidence_percentage: user_skill.confidence_percentage,
-
last_demonstrated_at: user_skill.last_demonstrated_at&.to_s,
-
max_years_experience: user_skill.max_years_experience
-
}.compact,
-
work_experiences: skill_work_experiences(user_skill),
-
source_resumes: source_resume_names(user_skill)
-
}.compact
-
end
-
-
def skill_work_experiences(user_skill)
-
# Find work experiences where this skill was used
-
user.user_work_experiences
-
.joins(:skill_tags)
-
.where(skill_tags: { id: user_skill.skill_tag_id })
-
.reverse_chronological
-
.limit(5)
-
.map do |exp|
-
{
-
company: exp.display_company_name,
-
role: exp.display_role_title,
-
dates: [ exp.start_date&.to_s, exp.end_date&.to_s ].compact.join(" - "),
-
is_current: exp.current?
-
}.compact
-
end
-
end
-
-
def source_resume_names(user_skill)
-
user_skill.source_resumes.limit(5).map do |resume|
-
{
-
id: resume.id,
-
name: resume.name,
-
analyzed_at: resume.analyzed_at&.iso8601
-
}
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Read-only: get details of a specific work experience.
-
class GetWorkExperienceTool < BaseTool
-
def call(args:, tool_execution:)
-
experience_id = (args["experience_id"] || args[:experience_id]).to_i
-
-
if experience_id.zero?
-
return { success: false, error: "experience_id is required" }
-
end
-
-
experience = user.user_work_experiences.find_by(id: experience_id)
-
-
if experience.nil?
-
return { success: false, error: "Work experience not found" }
-
end
-
-
{
-
success: true,
-
data: format_experience_detail(experience)
-
}
-
rescue StandardError => e
-
{ success: false, error: e.message }
-
end
-
-
private
-
-
def format_experience_detail(exp)
-
{
-
id: exp.id,
-
company: {
-
name: exp.display_company_name,
-
id: exp.company_id
-
},
-
role: {
-
title: exp.display_role_title,
-
id: exp.job_role_id
-
},
-
start_date: exp.start_date&.to_s,
-
end_date: exp.end_date&.to_s,
-
is_current: exp.current?,
-
duration_months: calculate_duration_months(exp),
-
highlights: Array(exp.highlights),
-
responsibilities: Array(exp.responsibilities),
-
skills: exp.skill_tags.pluck(:name),
-
source_type: exp.source_type,
-
source_count: exp.source_count,
-
created_at: exp.created_at&.iso8601,
-
updated_at: exp.updated_at&.iso8601
-
}.compact
-
end
-
-
def calculate_duration_months(exp)
-
return nil unless exp.start_date
-
-
end_date = exp.current? ? Date.current : (exp.end_date || Date.current)
-
months = ((end_date.year - exp.start_date.year) * 12) + (end_date.month - exp.start_date.month)
-
months.clamp(0, 600) # Cap at 50 years
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Read-only: list interview applications for the current user.
-
class ListInterviewApplicationsTool < BaseTool
-
def call(args:, tool_execution:)
-
status = normalize_filter_value(args["status"] || args[:status])
-
stage = normalize_filter_value(args["pipeline_stage"] || args[:pipeline_stage])
-
limit = (args["limit"] || args[:limit] || 20).to_i.clamp(1, 50)
-
-
scope = user.interview_applications.includes(:company, :job_role).order(created_at: :desc)
-
scope = scope.where(status: status) if status.present?
-
scope = scope.where(pipeline_stage: stage) if stage.present?
-
-
apps = scope.limit(limit)
-
-
{
-
success: true,
-
data: {
-
count: apps.size,
-
applications: apps.map { |a|
-
next_round = a.interview_rounds.upcoming.order(:scheduled_at).first
-
{
-
uuid: a.uuid,
-
id: a.id,
-
status: a.status,
-
pipeline_stage: a.pipeline_stage,
-
company: a.display_company&.name,
-
job_role: a.display_job_role&.title,
-
applied_at: a.applied_at,
-
next_interview: next_round ? serialize_round(next_round) : nil
-
}
-
}
-
}
-
}
-
rescue StandardError => e
-
{ success: false, error: e.message }
-
end
-
-
private
-
-
# Normalizes "all"/blank filter values to nil.
-
#
-
# LLMs commonly emit sentinel values like "all" even when the schema doesn't require it.
-
# Treat these as "no filter" so the tool behaves intuitively.
-
#
-
# @param value [Object]
-
# @return [String, nil]
-
def normalize_filter_value(value)
-
v = value.to_s.strip
-
return nil if v.blank?
-
return nil if v.casecmp("all").zero?
-
return nil if v.casecmp("any").zero?
-
-
v
-
end
-
-
def serialize_round(round)
-
{
-
id: round.id,
-
stage: round.stage,
-
stage_name: round.stage_display_name,
-
scheduled_at: round.scheduled_at,
-
interviewer: round.interviewer_display,
-
duration_minutes: round.duration_minutes
-
}
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Read-only: list the user's skills with proficiency levels.
-
class ListSkillsTool < BaseTool
-
VALID_FILTERS = %w[strong moderate developing all].freeze
-
-
def call(args:, tool_execution:)
-
limit = (args["limit"] || args[:limit] || 25).to_i.clamp(1, 100)
-
category = (args["category"] || args[:category]).to_s.presence
-
filter = (args["filter"] || args[:filter] || "all").to_s
-
-
skills = build_query(limit, category, filter)
-
-
{
-
success: true,
-
data: {
-
count: skills.size,
-
total_count: user.user_skills.count,
-
filter_applied: filter,
-
category_filter: category,
-
skills: skills.map { |us| format_skill(us) },
-
categories: user.user_skills.distinct.pluck(:category).compact.sort
-
}
-
}
-
rescue StandardError => e
-
{ success: false, error: e.message }
-
end
-
-
private
-
-
def build_query(limit, category, filter)
-
base = user.user_skills.includes(:skill_tag)
-
-
# Apply category filter
-
base = base.by_category(category) if category.present?
-
-
# Apply proficiency filter
-
base = case filter
-
when "strong"
-
base.strong_skills
-
when "moderate"
-
base.moderate_skills
-
when "developing"
-
base.developing_skills
-
else
-
base
-
end
-
-
base.by_level_desc.limit(limit)
-
end
-
-
def format_skill(user_skill)
-
{
-
id: user_skill.id,
-
skill_id: user_skill.skill_tag_id,
-
name: user_skill.skill_tag&.name,
-
category: user_skill.category,
-
proficiency_level: user_skill.aggregated_level&.round(2),
-
proficiency_label: user_skill.proficiency_label,
-
resume_count: user_skill.resume_count,
-
confidence: user_skill.confidence_score&.round(2),
-
last_demonstrated_at: user_skill.last_demonstrated_at&.to_s,
-
is_strong: user_skill.strong?,
-
is_developing: user_skill.developing?
-
}.compact
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Read-only: list the user's target companies.
-
class ListTargetCompaniesTool < BaseTool
-
def call(args:, tool_execution:)
-
limit = (args["limit"] || args[:limit] || 50).to_i.clamp(1, 100)
-
-
target_companies = user.user_target_companies
-
.includes(:company)
-
.ordered
-
.limit(limit)
-
-
{
-
success: true,
-
data: {
-
count: target_companies.size,
-
target_companies: target_companies.map { |utc|
-
{
-
id: utc.id,
-
company_id: utc.company_id,
-
company_name: utc.company&.name,
-
priority: utc.priority,
-
created_at: utc.created_at&.iso8601
-
}
-
}
-
}
-
}
-
rescue StandardError => e
-
{ success: false, error: e.message }
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Read-only: list the user's target domains.
-
class ListTargetDomainsTool < BaseTool
-
def call(args:, tool_execution:)
-
limit = (args["limit"] || args[:limit] || 50).to_i.clamp(1, 100)
-
-
target_domains = user.user_target_domains
-
.includes(:domain)
-
.ordered
-
.limit(limit)
-
-
{
-
success: true,
-
data: {
-
count: target_domains.size,
-
target_domains: target_domains.map { |utd|
-
{
-
id: utd.id,
-
domain_id: utd.domain_id,
-
domain_name: utd.domain&.name,
-
priority: utd.priority,
-
created_at: utd.created_at&.iso8601
-
}
-
}
-
}
-
}
-
rescue StandardError => e
-
{ success: false, error: e.message }
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Read-only: list the user's target job roles.
-
class ListTargetJobRolesTool < BaseTool
-
def call(args:, tool_execution:)
-
limit = (args["limit"] || args[:limit] || 50).to_i.clamp(1, 100)
-
-
target_roles = user.user_target_job_roles
-
.includes(:job_role)
-
.ordered
-
.limit(limit)
-
-
{
-
success: true,
-
data: {
-
count: target_roles.size,
-
target_job_roles: target_roles.map { |utjr|
-
{
-
id: utjr.id,
-
job_role_id: utjr.job_role_id,
-
job_role_title: utjr.job_role&.title,
-
priority: utjr.priority,
-
created_at: utjr.created_at&.iso8601
-
}
-
}
-
}
-
}
-
rescue StandardError => e
-
{ success: false, error: e.message }
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Read-only: list the user's work history (UserWorkExperience records).
-
class ListWorkHistoryTool < BaseTool
-
def call(args:, tool_execution:)
-
limit = (args["limit"] || args[:limit] || 20).to_i.clamp(1, 50)
-
include_skills = args["include_skills"] != false && args[:include_skills] != false
-
-
experiences = user.user_work_experiences
-
.reverse_chronological
-
.limit(limit)
-
-
experiences = experiences.includes(:skill_tags) if include_skills
-
-
{
-
success: true,
-
data: {
-
count: experiences.size,
-
total_count: user.user_work_experiences.count,
-
work_history: experiences.map { |exp| format_experience(exp, include_skills) }
-
}
-
}
-
rescue StandardError => e
-
{ success: false, error: e.message }
-
end
-
-
private
-
-
def format_experience(exp, include_skills)
-
result = {
-
id: exp.id,
-
company: exp.display_company_name,
-
role: exp.display_role_title,
-
start_date: exp.start_date&.to_s,
-
end_date: exp.end_date&.to_s,
-
is_current: exp.current?,
-
duration_months: calculate_duration_months(exp),
-
highlights: Array(exp.highlights).first(5),
-
responsibilities: Array(exp.responsibilities).first(5),
-
source_type: exp.source_type
-
}.compact
-
-
if include_skills
-
result[:skills] = exp.skill_tags.pluck(:name)
-
end
-
-
result
-
end
-
-
def calculate_duration_months(exp)
-
return nil unless exp.start_date
-
-
end_date = exp.current? ? Date.current : (exp.end_date || Date.current)
-
months = ((end_date.year - exp.start_date.year) * 12) + (end_date.month - exp.start_date.month)
-
months.clamp(0, 600) # Cap at 50 years
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Write: remove one or more companies from the user's target companies list.
-
#
-
# args:
-
# - company_id (optional)
-
# - company_name (optional, used to find)
-
# - companies (optional, array of {company_id?, company_name?})
-
class RemoveTargetCompanyTool < BaseTool
-
def call(args:, tool_execution:)
-
if args["companies"].is_a?(Array) || args[:companies].is_a?(Array)
-
return remove_many(args)
-
end
-
-
remove_one(args)
-
rescue StandardError => e
-
{ success: false, error: e.message }
-
end
-
-
private
-
-
def remove_one(args)
-
company = find_company(args)
-
return { success: false, error: "Company not found" } if company.nil?
-
-
utc = UserTargetCompany.find_by(user: user, company: company)
-
if utc
-
utc.destroy!
-
{ success: true, data: { removed: true, company: { id: company.id, name: company.name } } }
-
else
-
# Idempotent: "already removed" is a success.
-
{ success: true, data: { removed: false, company: { id: company.id, name: company.name } } }
-
end
-
end
-
-
def remove_many(args)
-
items = args["companies"]
-
items = args[:companies] if items.nil?
-
items = Array(items)
-
-
results = items.map do |item|
-
item = item.is_a?(Hash) ? item : {}
-
r = remove_one(item)
-
{
-
input: item,
-
success: r[:success] == true,
-
data: r[:data],
-
error: r[:error]
-
}.compact
-
rescue StandardError => e
-
{ input: item, success: false, error: e.message }
-
end
-
-
successes = results.count { |r| r[:success] == true }
-
failures = results.count { |r| r[:success] == false }
-
-
{
-
success: failures.zero?,
-
data: {
-
removed_count: results.count { |r| r.dig(:data, :removed) == true },
-
not_found_or_noop_count: results.count { |r| r[:success] == true && r.dig(:data, :removed) == false },
-
failed_count: failures,
-
results: results
-
}
-
}
-
end
-
-
def find_company(args)
-
company_id = (args["company_id"] || args[:company_id]).to_i
-
return Company.find_by(id: company_id) if company_id.positive?
-
-
name = (args["company_name"] || args[:company_name]).to_s.strip
-
return nil if name.blank?
-
-
Company.where("lower(name) = ?", name.downcase).first
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Write: remove one or more domains from the user's target domains list.
-
#
-
# args:
-
# - domain_id (optional)
-
# - domain_name (optional, used to find)
-
# - domains (optional, array of {domain_id?, domain_name?})
-
class RemoveTargetDomainTool < BaseTool
-
def call(args:, tool_execution:)
-
if args["domains"].is_a?(Array) || args[:domains].is_a?(Array)
-
return remove_many(args)
-
end
-
-
remove_one(args)
-
rescue StandardError => e
-
{ success: false, error: e.message }
-
end
-
-
private
-
-
def remove_one(args)
-
domain = find_domain(args)
-
return { success: false, error: "Domain not found" } if domain.nil?
-
-
utd = UserTargetDomain.find_by(user: user, domain: domain)
-
if utd
-
utd.destroy!
-
{ success: true, data: { removed: true, domain: { id: domain.id, name: domain.name } } }
-
else
-
# Idempotent: "already removed" is a success.
-
{ success: true, data: { removed: false, domain: { id: domain.id, name: domain.name } } }
-
end
-
end
-
-
def remove_many(args)
-
items = args["domains"]
-
items = args[:domains] if items.nil?
-
items = Array(items)
-
-
results = items.map do |item|
-
item = item.is_a?(Hash) ? item : {}
-
r = remove_one(item)
-
{
-
input: item,
-
success: r[:success] == true,
-
data: r[:data],
-
error: r[:error]
-
}.compact
-
rescue StandardError => e
-
{ input: item, success: false, error: e.message }
-
end
-
-
successes = results.count { |r| r[:success] == true }
-
failures = results.count { |r| r[:success] == false }
-
-
{
-
success: failures.zero?,
-
data: {
-
removed_count: results.count { |r| r.dig(:data, :removed) == true },
-
not_found_or_noop_count: results.count { |r| r[:success] == true && r.dig(:data, :removed) == false },
-
failed_count: failures,
-
results: results
-
}
-
}
-
end
-
-
def find_domain(args)
-
domain_id = (args["domain_id"] || args[:domain_id]).to_i
-
return Domain.find_by(id: domain_id) if domain_id.positive?
-
-
name = (args["domain_name"] || args[:domain_name]).to_s.strip
-
return nil if name.blank?
-
-
Domain.where("lower(name) = ?", name.downcase).first
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Write: remove one or more job roles from the user's target job roles list.
-
#
-
# args:
-
# - job_role_id (optional)
-
# - job_role_title (optional, used to find)
-
# - job_roles (optional, array of {job_role_id?, job_role_title?})
-
class RemoveTargetJobRoleTool < BaseTool
-
def call(args:, tool_execution:)
-
if args["job_roles"].is_a?(Array) || args[:job_roles].is_a?(Array)
-
return remove_many(args)
-
end
-
-
remove_one(args)
-
rescue StandardError => e
-
{ success: false, error: e.message }
-
end
-
-
private
-
-
def remove_one(args)
-
role = find_job_role(args)
-
return { success: false, error: "Job role not found" } if role.nil?
-
-
utjr = UserTargetJobRole.find_by(user: user, job_role: role)
-
if utjr
-
utjr.destroy!
-
{ success: true, data: { removed: true, job_role: { id: role.id, title: role.title } } }
-
else
-
{ success: true, data: { removed: false, job_role: { id: role.id, title: role.title } } }
-
end
-
end
-
-
def remove_many(args)
-
items = args["job_roles"]
-
items = args[:job_roles] if items.nil?
-
items = Array(items)
-
-
results = items.map do |item|
-
item = item.is_a?(Hash) ? item : {}
-
r = remove_one(item)
-
{
-
input: item,
-
success: r[:success] == true,
-
data: r[:data],
-
error: r[:error]
-
}.compact
-
rescue StandardError => e
-
{ input: item, success: false, error: e.message }
-
end
-
-
failures = results.count { |r| r[:success] == false }
-
-
{
-
success: failures.zero?,
-
data: {
-
removed_count: results.count { |r| r.dig(:data, :removed) == true },
-
not_found_or_noop_count: results.count { |r| r[:success] == true && r.dig(:data, :removed) == false },
-
failed_count: failures,
-
results: results
-
}
-
}
-
end
-
-
def find_job_role(args)
-
job_role_id = (args["job_role_id"] || args[:job_role_id]).to_i
-
return JobRole.find_by(id: job_role_id) if job_role_id.positive?
-
-
title = (args["job_role_title"] || args[:job_role_title]).to_s.strip
-
return nil if title.blank?
-
-
JobRole.where("lower(title) = ?", title.downcase).first
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Write: update user profile attributes.
-
#
-
# Updateable attributes:
-
# - years_of_experience (integer)
-
# - current_company_name (string, finds or creates Company)
-
# - current_job_role_title (string, finds or creates JobRole)
-
# - linkedin_url, github_url, twitter_url, portfolio_url, gitlab_url (strings)
-
# - bio (text)
-
#
-
# NOTE: Does not allow updating email or password for security.
-
class UpdateProfileTool < BaseTool
-
ALLOWED_ATTRIBUTES = %w[
-
years_of_experience
-
current_company_name
-
current_job_role_title
-
linkedin_url
-
github_url
-
twitter_url
-
portfolio_url
-
gitlab_url
-
bio
-
].freeze
-
-
def call(args:, tool_execution:)
-
updates = extract_updates(args)
-
-
if updates.empty?
-
return { success: false, error: "No valid attributes provided to update" }
-
end
-
-
changes = apply_updates(updates)
-
-
{
-
success: true,
-
data: {
-
updated_attributes: changes.keys,
-
profile: build_profile_summary
-
}
-
}
-
rescue ActiveRecord::RecordInvalid => e
-
{ success: false, error: e.record.errors.full_messages.join(", ") }
-
rescue StandardError => e
-
{ success: false, error: e.message }
-
end
-
-
private
-
-
def extract_updates(args)
-
updates = {}
-
-
ALLOWED_ATTRIBUTES.each do |attr|
-
value = args[attr] || args[attr.to_sym]
-
updates[attr] = value if value.present? || value == "" # Allow clearing with empty string
-
end
-
-
updates
-
end
-
-
def apply_updates(updates)
-
changes = {}
-
-
# Handle company - find or create
-
if updates.key?("current_company_name")
-
company_name = updates["current_company_name"].to_s.strip
-
if company_name.blank?
-
user.current_company = nil
-
changes[:current_company] = nil
-
else
-
company = Company.where("lower(name) = ?", company_name.downcase).first ||
-
Company.create!(name: company_name)
-
user.current_company = company
-
changes[:current_company] = company.name
-
end
-
end
-
-
# Handle job role - find or create
-
if updates.key?("current_job_role_title")
-
role_title = updates["current_job_role_title"].to_s.strip
-
if role_title.blank?
-
user.current_job_role = nil
-
changes[:current_job_role] = nil
-
else
-
job_role = JobRole.where("lower(title) = ?", role_title.downcase).first ||
-
JobRole.create!(title: role_title)
-
user.current_job_role = job_role
-
changes[:current_job_role] = job_role.title
-
end
-
end
-
-
# Handle years of experience
-
if updates.key?("years_of_experience")
-
value = updates["years_of_experience"]
-
user.years_of_experience = value.present? ? value.to_i.clamp(0, 60) : nil
-
changes[:years_of_experience] = user.years_of_experience
-
end
-
-
# Handle social URLs
-
%w[linkedin_url github_url twitter_url portfolio_url gitlab_url].each do |attr|
-
if updates.key?(attr)
-
value = updates[attr].to_s.strip
-
user.send("#{attr}=", value.presence)
-
changes[attr.to_sym] = value.presence
-
end
-
end
-
-
# Handle bio
-
if updates.key?("bio")
-
value = updates["bio"].to_s.strip
-
user.bio = value.presence
-
changes[:bio] = value.present? ? "updated" : "cleared"
-
end
-
-
user.save!
-
changes
-
end
-
-
def build_profile_summary
-
{
-
name: user.name,
-
years_of_experience: user.years_of_experience,
-
current_company: user.current_company&.name,
-
current_job_role: user.current_job_role&.title,
-
bio: user.bio.present? ? user.bio.truncate(100) : nil,
-
social_profiles: {
-
linkedin: user.linkedin_url.presence,
-
github: user.github_url.presence,
-
twitter: user.twitter_url.presence,
-
portfolio: user.portfolio_url.presence,
-
gitlab: user.gitlab_url.presence
-
}.compact
-
}.compact
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Assistant
-
module Tools
-
# Write: create or update manual interview feedback for a round.
-
class UpsertInterviewFeedbackTool < BaseTool
-
def call(args:, tool_execution:)
-
round_id = (args["interview_round_id"] || args[:interview_round_id]).to_i
-
return { success: false, error: "interview_round_id is required" } if round_id <= 0
-
-
round = InterviewRound.includes(:interview_application, :interview_feedback).find_by(id: round_id)
-
return { success: false, error: "Interview round not found" } if round.nil?
-
return { success: false, error: "Not authorized" } unless round.interview_application.user_id == user.id
-
-
fb = round.interview_feedback || round.build_interview_feedback
-
-
fb.went_well = args["went_well"] || args[:went_well] if args.key?("went_well") || args.key?(:went_well)
-
fb.to_improve = args["to_improve"] || args[:to_improve] if args.key?("to_improve") || args.key?(:to_improve)
-
fb.self_reflection = args["self_reflection"] || args[:self_reflection] if args.key?("self_reflection") || args.key?(:self_reflection)
-
fb.interviewer_notes = args["interviewer_notes"] || args[:interviewer_notes] if args.key?("interviewer_notes") || args.key?(:interviewer_notes)
-
fb.recommended_action = args["recommended_action"] || args[:recommended_action] if args.key?("recommended_action") || args.key?(:recommended_action)
-
-
tags = args["tags"] || args[:tags]
-
fb.tag_list = tags if tags.present?
-
-
fb.save!
-
-
{
-
success: true,
-
data: {
-
interview_round_id: round.id,
-
interview_feedback_id: fb.id
-
}
-
}
-
rescue ActiveRecord::RecordInvalid => e
-
{ success: false, error: e.record.errors.full_messages.join(", ") }
-
rescue StandardError => e
-
{ success: false, error: e.message }
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
require "json_schemer"
-
-
module Signals
-
module Contracts
-
module Validators
-
# Validates JSON objects against Draft 2020-12 JSON Schemas.
-
#
-
# Schemas in this repo use stable `$id` URIs like:
-
# gleania://signals/contracts/schemas/decision_input.schema.json
-
#
-
# This validator resolves those `$ref`s to files under:
-
# app/domains/signals/contracts/schemas/
-
class JsonSchemaValidator
-
SCHEMA_ID_PREFIX = "gleania://signals/contracts/schemas/".freeze
-
-
def initialize(schema_id:)
-
@schema_id = schema_id
-
end
-
-
def valid?(data)
-
errors_for(data).empty?
-
end
-
-
# @return [Array<Hash>] json_schemer error hashes
-
def errors_for(data)
-
schemer.validate(data).to_a
-
end
-
-
private
-
-
attr_reader :schema_id
-
-
def schemer
-
@schemer ||= JSONSchemer.schema(root_schema_json, ref_resolver: method(:resolve_ref))
-
end
-
-
def root_schema_json
-
JSON.parse(File.read(path_for_schema_id(schema_id)))
-
end
-
-
# Resolve `$ref` schema IDs (gleania://...) to local files.
-
def resolve_ref(uri)
-
uri_str = uri.to_s
-
base_uri = uri_str.split("#", 2).first
-
return nil unless base_uri.start_with?(SCHEMA_ID_PREFIX)
-
-
path = path_for_schema_id(base_uri)
-
JSON.parse(File.read(path))
-
end
-
-
def path_for_schema_id(uri)
-
raise ArgumentError, "Unsupported schema id: #{uri}" unless uri.start_with?(SCHEMA_ID_PREFIX)
-
-
relative = uri.delete_prefix(SCHEMA_ID_PREFIX)
-
File.join(Rails.root, "app/domains/signals/contracts/schemas", relative)
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Decisioning
-
# Builds a schema-valid DecisionInput using the current application/email state.
-
#
-
# Note: This is a *builder* only. It does not write to the DB.
-
class DecisionInputBuilder
-
VERSION = "2026-01-27".freeze
-
-
def initialize(synced_email)
-
@synced_email = synced_email
-
end
-
-
def build(facts: nil)
-
base = build_base
-
app = synced_email.interview_application
-
base.merge("facts" => (facts || build_fallback_facts(app)))
-
end
-
-
# Base DecisionInput without facts (used by EmailFacts extraction).
-
def build_base
-
app = synced_email.interview_application
-
event = Signals::Facts::CanonicalEmailEventBuilder.new(synced_email).build
-
-
{
-
"version" => VERSION,
-
"event" => event,
-
"match" => build_match(app),
-
"application" => app ? build_application_snapshot(app) : nil
-
}
-
end
-
-
private
-
-
attr_reader :synced_email
-
-
def build_match(app)
-
{
-
"matched" => synced_email.matched?,
-
"match_strategy" => nil,
-
"interview_application_id" => app&.id,
-
"confidence" => synced_email.matched? ? 0.5 : 0.0
-
}
-
end
-
-
def build_application_snapshot(app)
-
rounds = app.interview_rounds.ordered.last(10).map do |r|
-
{
-
"id" => r.id,
-
"position" => r.position,
-
"stage" => r.stage,
-
"stage_name" => r.stage_name,
-
"scheduled_at" => r.scheduled_at&.iso8601,
-
"result" => r.result,
-
"interviewer_name" => r.interviewer_name,
-
"source_email_id" => r.source_email_id
-
}
-
end
-
-
{
-
"id" => app.id,
-
"status" => app.status,
-
"pipeline_stage" => app.pipeline_stage,
-
"company" => {
-
"id" => app.company_id,
-
"name" => app.company&.name,
-
"website" => app.company&.website
-
},
-
"job_role" => {
-
"id" => app.job_role_id,
-
"title" => app.job_role&.title
-
},
-
"rounds_recent" => rounds
-
}
-
end
-
-
public
-
-
def build_fallback_facts(app)
-
email_type = synced_email.email_type.to_s
-
kind = map_kind(email_type)
-
-
{
-
"extraction" => {
-
"provider" => nil,
-
"model" => nil,
-
"confidence" => (synced_email.extraction_confidence || 0.0).to_f,
-
"warnings" => []
-
},
-
"classification" => {
-
"kind" => kind,
-
"confidence" => kind == "unknown" ? 0.0 : 0.5,
-
"evidence" => [synced_email.subject.to_s.presence || synced_email.snippet.to_s.presence || "classified"].compact
-
},
-
"entities" => {
-
"company" => {
-
"name" => synced_email.signal_company_name || app&.company&.name,
-
"website" => synced_email.signal_company_website || app&.company&.website
-
},
-
"recruiter" => {
-
"name" => synced_email.signal_recruiter_name,
-
"email" => synced_email.signal_recruiter_email,
-
"title" => synced_email.signal_recruiter_title
-
},
-
"job" => {
-
"title" => synced_email.signal_job_title || app&.job_role&.title,
-
"department" => synced_email.signal_job_department,
-
"location" => synced_email.signal_job_location,
-
"url" => synced_email.signal_job_url
-
}
-
},
-
"action_links" => Array(synced_email.signal_action_links).map do |l|
-
next unless l.is_a?(Hash)
-
url = l["url"].to_s
-
label = l["action_label"].to_s
-
next if url.blank? || label.blank?
-
{ "url" => url, "action_label" => label, "priority" => (l["priority"] || 5).to_i }
-
end.compact.first(20),
-
"key_insights" => synced_email.extracted_data&.dig("key_insights"),
-
"is_forwarded" => !!synced_email.extracted_data&.dig("is_forwarded"),
-
"scheduling" => empty_scheduling,
-
"round_feedback" => empty_round_feedback,
-
"status_change" => status_change_stub(email_type)
-
}
-
end
-
-
private
-
-
def map_kind(email_type)
-
case email_type
-
when "scheduling", "interview_reminder" then "scheduling"
-
when "interview_invite" then "interview_invite"
-
when "round_feedback" then "round_feedback"
-
when "rejection", "offer" then "status_update"
-
when "application_confirmation" then "application_confirmation"
-
when "recruiter_outreach" then "recruiter_outreach"
-
when "assessment" then "interview_assessment"
-
when "", nil then "unknown"
-
else "other"
-
end
-
end
-
-
def empty_scheduling
-
{
-
"is_scheduling_related" => false,
-
"scheduled_at" => nil,
-
"timezone_hint" => nil,
-
"duration_minutes" => 0,
-
"stage" => nil,
-
"round_type" => nil,
-
"stage_name" => nil,
-
"interviewer_name" => nil,
-
"interviewer_role" => nil,
-
"video_link" => nil,
-
"phone_number" => nil,
-
"location" => nil,
-
"is_rescheduled" => false,
-
"is_cancelled" => false,
-
"original_scheduled_at" => nil,
-
"evidence" => []
-
}
-
end
-
-
def empty_round_feedback
-
{
-
"has_round_feedback" => false,
-
"result" => nil,
-
"stage_mentioned" => nil,
-
"round_type" => nil,
-
"interviewer_mentioned" => nil,
-
"date_mentioned" => nil,
-
"feedback" => {
-
"has_detailed_feedback" => false,
-
"summary" => nil,
-
"strengths" => [],
-
"improvements" => [],
-
"full_feedback_text" => nil
-
},
-
"next_steps" => {
-
"has_next_round" => false,
-
"next_round_type" => nil,
-
"next_round_hint" => nil,
-
"timeline_hint" => nil
-
},
-
"evidence" => []
-
}
-
end
-
-
def status_change_stub(email_type)
-
type = case email_type
-
when "rejection" then "rejection"
-
when "offer" then "offer"
-
else "no_change"
-
end
-
-
{
-
"has_status_change" => %w[rejection offer].include?(email_type),
-
"type" => type,
-
"is_final" => (email_type == "rejection") ? true : nil,
-
"effective_date" => synced_email.email_date&.iso8601,
-
"rejection_details" => { "reason" => nil, "stage_rejected_at" => nil, "is_generic" => false, "door_open" => false },
-
"offer_details" => {
-
"role_title" => nil,
-
"department" => nil,
-
"start_date" => nil,
-
"response_deadline" => nil,
-
"includes_compensation_info" => false,
-
"compensation_hints" => nil,
-
"next_steps" => nil
-
},
-
"feedback" => { "has_feedback" => false, "feedback_text" => nil, "is_constructive" => false },
-
"evidence" => []
-
}
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Signals
-
module Decisioning
-
module Execution
-
class Dispatcher
-
def initialize(synced_email, pipeline_recorder: nil)
-
@synced_email = synced_email
-
@pipeline_recorder = pipeline_recorder
-
end
-
-
def dispatch(step)
-
action = step["action"].to_s
-
return nil if action == "noop"
-
-
if requires_application?(action) && !synced_email.matched?
-
res = {
-
"step_id" => step["step_id"],
-
"action" => action,
-
"status" => "skipped_no_application"
-
}
-
emit_skipped_step_event(action, step, res)
-
return res
-
end
-
-
preconditions = Array(step["preconditions"])
-
guard = Signals::Decisioning::Execution::PreconditionEvaluator.evaluate_all(preconditions, synced_email: synced_email, step: step)
-
unless guard[:ok]
-
res = {
-
"step_id" => step["step_id"],
-
"action" => action,
-
"status" => "skipped_precondition_failed",
-
"failed_preconditions" => guard[:failed],
-
"unknown_preconditions" => guard[:unknown]
-
}
-
emit_skipped_step_event(action, step, res)
-
return res
-
end
-
-
handler_class = handler_for(action)
-
unless handler_class
-
res = { "step_id" => step["step_id"], "status" => "skipped_unknown_action", "action" => action }
-
emit_skipped_step_event(action, step, res)
-
return res
-
end
-
-
handler = handler_class.new(synced_email)
-
-
if pipeline_recorder
-
pipeline_recorder.measure(
-
:"execute_#{action}",
-
input_payload: {
-
"step_id" => step["step_id"],
-
"action" => action,
-
"target" => step["target"],
-
"params" => step["params"]
-
},
-
output_payload_override: ->(result) { { "result" => result } }
-
) { handler.call(step) }
-
else
-
handler.call(step)
-
end
-
end
-
-
private
-
-
attr_reader :synced_email, :pipeline_recorder
-
-
def emit_skipped_step_event(action, step, result)
-
return unless pipeline_recorder
-
return unless Signals::EmailPipelineEvent.event_types.key?("execute_#{action}")
-
-
pipeline_recorder.event!(
-
event_type: :"execute_#{action}",
-
status: :skipped,
-
input_payload: {
-
"step_id" => step["step_id"],
-
"action" => action,
-
"target" => step["target"],
-
"params" => step["params"],
-
"preconditions" => step["preconditions"]
-
},
-
output_payload: { "result" => result }
-
)
-
end
-
-
def handler_for(action)
-
case action
-
when "set_pipeline_stage" then Signals::Decisioning::Execution::Handlers::SetPipelineStage
-
when "set_application_status" then Signals::Decisioning::Execution::Handlers::SetApplicationStatus
-
when "create_round" then Signals::Decisioning::Execution::Handlers::CreateRound
-
when "update_round" then Signals::Decisioning::Execution::Handlers::UpdateRound
-
when "set_round_result" then Signals::Decisioning::Execution::Handlers::SetRoundResult
-
when "create_interview_feedback" then Signals::Decisioning::Execution::Handlers::CreateInterviewFeedback
-
when "create_company_feedback" then Signals::Decisioning::Execution::Handlers::CreateCompanyFeedback
-
when "create_opportunity" then Signals::Decisioning::Execution::Handlers::CreateOpportunity
-
when "upsert_job_listing_from_url" then Signals::Decisioning::Execution::Handlers::UpsertJobListingFromUrl
-
when "attach_job_listing_to_opportunity" then Signals::Decisioning::Execution::Handlers::AttachJobListingToOpportunity
-
when "enqueue_scrape_job_listing" then Signals::Decisioning::Execution::Handlers::EnqueueScrapeJobListing
-
else nil
-
end
-
end
-
-
def requires_application?(action)
-
%w[
-
set_pipeline_stage
-
set_application_status
-
create_round
-
update_round
-
set_round_result
-
create_interview_feedback
-
create_company_feedback
-
].include?(action)
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Decisioning
-
module Execution
-
module Handlers
-
class AttachJobListingToOpportunity < BaseHandler
-
def call(step)
-
params = step["params"] || {}
-
url = params["url"].to_s
-
return { "action" => "attach_job_listing_to_opportunity", "status" => "no_url" } if url.blank?
-
-
opportunity = Opportunity.find_by(synced_email_id: synced_email.id)
-
return { "action" => "attach_job_listing_to_opportunity", "status" => "no_opportunity" } unless opportunity
-
-
job_listing = JobListing.find_by(url: normalize_url(url)) || JobListing.find_by(url: url)
-
return { "action" => "attach_job_listing_to_opportunity", "status" => "no_job_listing" } unless job_listing
-
-
if opportunity.job_listing_id == job_listing.id
-
return { "action" => "attach_job_listing_to_opportunity", "status" => "already_attached", "opportunity_id" => opportunity.id, "job_listing_id" => job_listing.id }
-
end
-
-
opportunity.update!(job_listing: job_listing)
-
{ "action" => "attach_job_listing_to_opportunity", "opportunity_id" => opportunity.id, "job_listing_id" => job_listing.id }
-
end
-
-
private
-
-
def normalize_url(url)
-
uri = URI.parse(url.strip)
-
return url.strip unless uri.query.present?
-
-
params = URI.decode_www_form(uri.query).reject do |key, _|
-
%w[utm_source utm_medium utm_campaign utm_content utm_term ref source].include?(key.downcase)
-
end
-
uri.query = params.any? ? URI.encode_www_form(params) : nil
-
uri.to_s
-
rescue URI::InvalidURIError
-
url.strip
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Decisioning
-
module Execution
-
module Handlers
-
class BaseHandler
-
def initialize(synced_email)
-
@synced_email = synced_email
-
end
-
-
private
-
-
attr_reader :synced_email
-
-
def app
-
synced_email.interview_application
-
end
-
-
def resolve_round(target)
-
return nil unless app
-
-
sel = target.dig("round", "selector")
-
case sel
-
when "by_id"
-
id = target.dig("round", "id")
-
app.interview_rounds.find_by(id: id)
-
when "latest_pending"
-
app.interview_rounds.where(result: :pending).order(scheduled_at: :desc).first
-
when "latest"
-
app.interview_rounds.ordered.last
-
else
-
nil
-
end
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Decisioning
-
module Execution
-
module Handlers
-
class CreateCompanyFeedback < BaseHandler
-
def call(step)
-
return { "action" => "create_company_feedback", "status" => "no_application" } unless app
-
-
params = step["params"] || {}
-
existing = CompanyFeedback.find_by(interview_application_id: app.id)
-
if existing
-
# Idempotency: avoid duplicate feedback due to association caching during replays.
-
if existing.source_email_id == synced_email.id
-
return {
-
"action" => "create_company_feedback",
-
"status" => "already_exists",
-
"feedback_id" => existing.id
-
}
-
end
-
-
return {
-
"action" => "create_company_feedback",
-
"status" => "already_exists",
-
"feedback_id" => existing.id
-
}
-
end
-
-
fb = CompanyFeedback.create!(
-
interview_application: app,
-
source_email_id: synced_email.id,
-
feedback_type: params["feedback_type"],
-
feedback_text: params["feedback_text"],
-
rejection_reason: params["rejection_reason"],
-
next_steps: params["next_steps"],
-
received_at: synced_email.email_date || Time.current
-
)
-
-
{ "action" => "create_company_feedback", "feedback_id" => fb.id }
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Decisioning
-
module Execution
-
module Handlers
-
class CreateInterviewFeedback < BaseHandler
-
def call(step)
-
params = step["params"] || {}
-
round = resolve_round(step["target"] || {})
-
return { "action" => "create_interview_feedback", "status" => "no_round_resolved" } unless round
-
existing = InterviewFeedback.find_by(interview_round_id: round.id)
-
if existing
-
return {
-
"action" => "create_interview_feedback",
-
"status" => "already_exists",
-
"round_id" => round.id,
-
"feedback_id" => existing.id
-
}
-
end
-
-
fb = InterviewFeedback.create!(
-
interview_round: round,
-
went_well: params["went_well"],
-
to_improve: params["to_improve"],
-
ai_summary: params["ai_summary"],
-
interviewer_notes: params["interviewer_notes"],
-
recommended_action: params["recommended_action"]
-
)
-
-
{ "action" => "create_interview_feedback", "round_id" => round.id, "feedback_id" => fb.id }
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Decisioning
-
module Execution
-
module Handlers
-
class CreateOpportunity < BaseHandler
-
def call(step)
-
params = step["params"] || {}
-
synced_email_id = params.dig("source", "synced_email_id") || synced_email.id
-
-
existing = Opportunity.find_by(synced_email_id: synced_email_id)
-
if existing
-
return { "action" => "create_opportunity", "status" => "already_exists", "opportunity_id" => existing.id }
-
end
-
-
opp = Opportunity.create!(
-
user: synced_email.user,
-
synced_email_id: synced_email_id,
-
status: "new",
-
company_name: params["company_name"],
-
job_role_title: params["job_title"],
-
job_url: params["job_url"],
-
recruiter_name: params["recruiter_name"],
-
recruiter_email: params["recruiter_email"],
-
extracted_links: params["extracted_links"] || [],
-
email_snippet: synced_email.snippet || synced_email.body_preview&.truncate(500)
-
)
-
-
{ "action" => "create_opportunity", "opportunity_id" => opp.id }
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Decisioning
-
module Execution
-
module Handlers
-
class CreateRound < BaseHandler
-
def call(step)
-
return { "action" => "create_round", "status" => "no_application" } unless app
-
-
params = step["params"] || {}
-
-
existing = app.interview_rounds.find_by(
-
source_email_id: synced_email.id,
-
stage: params["stage"],
-
stage_name: params["stage_name"],
-
scheduled_at: params["scheduled_at"]
-
)
-
if existing
-
return { "action" => "create_round", "status" => "already_exists", "round_id" => existing.id }
-
end
-
-
position = app.interview_rounds.maximum(:position).to_i + 1
-
-
round = app.interview_rounds.create!(
-
stage: params["stage"],
-
stage_name: params["stage_name"],
-
scheduled_at: params["scheduled_at"],
-
duration_minutes: params["duration_minutes"],
-
interviewer_name: params["interviewer_name"],
-
interviewer_role: params["interviewer_role"],
-
video_link: params["video_link"],
-
position: position,
-
notes: params["notes"],
-
source_email_id: synced_email.id
-
)
-
-
{ "action" => "create_round", "round_id" => round.id }
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Decisioning
-
module Execution
-
module Handlers
-
class EnqueueScrapeJobListing < BaseHandler
-
def call(step)
-
params = step["params"] || {}
-
url = params["url"].to_s
-
force = !!params["force"]
-
return { "action" => "enqueue_scrape_job_listing", "status" => "no_url" } if url.blank?
-
-
job_listing = JobListing.find_by(url: normalize_url(url)) || JobListing.find_by(url: url)
-
return { "action" => "enqueue_scrape_job_listing", "status" => "no_job_listing" } unless job_listing
-
-
if !force && job_listing.scraped?
-
return { "action" => "enqueue_scrape_job_listing", "status" => "already_scraped", "job_listing_id" => job_listing.id }
-
end
-
-
ScrapeJobListingJob.perform_later(job_listing)
-
{ "action" => "enqueue_scrape_job_listing", "job_listing_id" => job_listing.id }
-
end
-
-
private
-
-
def normalize_url(url)
-
uri = URI.parse(url.strip)
-
return url.strip unless uri.query.present?
-
-
params = URI.decode_www_form(uri.query).reject do |key, _|
-
%w[utm_source utm_medium utm_campaign utm_content utm_term ref source].include?(key.downcase)
-
end
-
uri.query = params.any? ? URI.encode_www_form(params) : nil
-
uri.to_s
-
rescue URI::InvalidURIError
-
url.strip
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Decisioning
-
module Execution
-
module Handlers
-
class SetApplicationStatus < BaseHandler
-
def call(step)
-
status = step.dig("params", "status")
-
ctx = Signals::StateContext.new(synced_email)
-
applier = Signals::ActionApplier.new(ctx)
-
res = applier.apply!([ { type: :set_application_status, status: status&.to_sym } ])
-
{ "action" => "set_application_status", "status" => status, "result" => res }
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Decisioning
-
module Execution
-
module Handlers
-
class SetPipelineStage < BaseHandler
-
def call(step)
-
stage = step.dig("params", "stage")
-
ctx = Signals::StateContext.new(synced_email)
-
applier = Signals::ActionApplier.new(ctx)
-
res = applier.apply!([ { type: :set_pipeline_stage, stage: stage&.to_sym } ])
-
{ "action" => "set_pipeline_stage", "stage" => stage, "result" => res }
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Decisioning
-
module Execution
-
module Handlers
-
class SetRoundResult < BaseHandler
-
def call(step)
-
params = step["params"] || {}
-
round = resolve_round(step["target"] || {})
-
return { "action" => "set_round_result", "status" => "no_round_resolved" } unless round
-
-
if round.result.to_s == params["result"].to_s && round.source_email_id == synced_email.id
-
return {
-
"action" => "set_round_result",
-
"status" => "already_set",
-
"round_id" => round.id,
-
"result" => round.result
-
}
-
end
-
-
round.update!(
-
result: params["result"],
-
completed_at: params["completed_at"] || Time.current,
-
source_email_id: synced_email.id
-
)
-
-
{ "action" => "set_round_result", "round_id" => round.id, "result" => round.result }
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Decisioning
-
module Execution
-
module Handlers
-
class UpdateRound < BaseHandler
-
def call(step)
-
params = step["params"] || {}
-
round = resolve_round(step["target"] || {})
-
return { "action" => "update_round", "status" => "no_round_resolved" } unless round
-
-
notes = round.notes.to_s
-
notes_append = params["notes_append"].to_s
-
if notes_append.present? && !notes.include?(notes_append)
-
notes = [ notes, notes_append ].reject(&:blank?).join("\n").presence.to_s
-
end
-
-
round.update!(
-
scheduled_at: params["scheduled_at"] || round.scheduled_at,
-
duration_minutes: params["duration_minutes"] || round.duration_minutes,
-
interviewer_name: params["interviewer_name"] || round.interviewer_name,
-
interviewer_role: params["interviewer_role"] || round.interviewer_role,
-
video_link: params["video_link"] || round.video_link,
-
notes: notes.presence,
-
source_email_id: synced_email.id
-
)
-
-
{ "action" => "update_round", "round_id" => round.id }
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Decisioning
-
module Execution
-
module Handlers
-
class UpsertJobListingFromUrl < BaseHandler
-
def call(step)
-
params = step["params"] || {}
-
url = params["url"].to_s
-
return { "action" => "upsert_job_listing_from_url", "status" => "no_url" } if url.blank?
-
-
company = find_or_create_company(params["company_name"])
-
job_role = find_or_create_job_role(params["job_role_title"] || params["job_title"])
-
-
res = JobListings::UpsertFromUrlService.new(
-
url: url,
-
company: company,
-
job_role: job_role,
-
title: params["job_title"].presence || job_role.title
-
).call
-
-
{
-
"action" => "upsert_job_listing_from_url",
-
"status" => (res[:created] ? "created" : "already_exists"),
-
"job_listing_id" => res[:job_listing].id
-
}
-
end
-
-
private
-
-
def find_or_create_company(name)
-
normalized = name.to_s.strip
-
normalized = "Unknown Company" if normalized.blank?
-
existing = Company.find_by("LOWER(name) = ?", normalized.downcase)
-
return existing if existing
-
-
Company.create!(name: normalized.titleize)
-
end
-
-
def find_or_create_job_role(title)
-
t = title.to_s.strip
-
t = "Unknown Position" if t.blank?
-
existing = JobRole.find_by("LOWER(title) = ?", t.downcase)
-
return existing if existing
-
-
JobRole.create!(title: t)
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Decisioning
-
module Execution
-
# Fail-closed evaluator for plan preconditions.
-
#
-
# IMPORTANT:
-
# - This does NOT eval Ruby.
-
# - Only known predicates are supported.
-
# - Unknown predicates fail closed (skip step).
-
class PreconditionEvaluator
-
class << self
-
def evaluate_all(preconditions, synced_email:, step:)
-
preds = Array(preconditions).map(&:to_s).map(&:strip).reject(&:blank?)
-
return { ok: true, failed: [], unknown: [] } if preds.empty?
-
-
failed = []
-
unknown = []
-
-
preds.each do |pred|
-
res = evaluate(pred, synced_email: synced_email, step: step)
-
if res == :unknown
-
unknown << pred
-
failed << pred
-
elsif res == false
-
failed << pred
-
end
-
end
-
-
{ ok: failed.empty?, failed: failed, unknown: unknown }
-
end
-
-
def evaluate(predicate, synced_email:, step:)
-
pred = predicate.to_s.strip
-
-
return synced_email.matched? if pred == "match.matched == true"
-
-
if (m = pred.match(/\Aapplication\.pipeline_stage != (\w+)\z/))
-
app = synced_email.interview_application
-
return false unless app
-
return app.pipeline_stage.to_s != m[1]
-
end
-
-
if (m = pred.match(/\Aapplication\.pipeline_stage == (\w+)\z/))
-
app = synced_email.interview_application
-
return false unless app
-
return app.pipeline_stage.to_s == m[1]
-
end
-
-
if (m = pred.match(/\Aapplication\.status == (\w+)\z/))
-
app = synced_email.interview_application
-
return false unless app
-
return app.status.to_s == m[1]
-
end
-
-
if pred == "application.company_feedback == null"
-
app = synced_email.interview_application
-
return false unless app
-
return app.company_feedback.nil?
-
end
-
-
if pred == "application.rounds_recent.any(result==pending) == true"
-
app = synced_email.interview_application
-
return false unless app
-
return app.interview_rounds.where(result: :pending).exists?
-
end
-
-
if pred == "round.interview_feedback == null"
-
round = resolve_round(synced_email, step["target"] || {})
-
return false unless round
-
return round.interview_feedback.nil?
-
end
-
-
:unknown
-
end
-
-
private
-
-
def resolve_round(synced_email, target)
-
app = synced_email.interview_application
-
return nil unless app
-
-
sel = target.dig("round", "selector")
-
case sel
-
when "by_id"
-
id = target.dig("round", "id")
-
app.interview_rounds.find_by(id: id)
-
when "latest_pending"
-
app.interview_rounds.where(result: :pending).order(scheduled_at: :desc).first
-
when "latest"
-
app.interview_rounds.ordered.last
-
else
-
nil
-
end
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Decisioning
-
# Executes a DecisionPlan with guardrails.
-
#
-
# This is intentionally conservative. If anything is invalid, it fails closed.
-
class ExecutionRunner < ApplicationService
-
DECISION_INPUT_SCHEMA_ID = "gleania://signals/contracts/schemas/decision_input.schema.json"
-
DECISION_PLAN_SCHEMA_ID = "gleania://signals/contracts/schemas/decision_plan.schema.json"
-
EMAIL_FACTS_SCHEMA_ID = "gleania://signals/contracts/schemas/components/facts/email_facts.schema.json"
-
-
EXECUTION_META_KEY = "decision_execution_v1"
-
-
def initialize(synced_email, pipeline_run: nil)
-
@synced_email = synced_email
-
@pipeline_recorder = Signals::Observability::EmailPipelineRecorder.for_run(pipeline_run)
-
end
-
-
def call
-
return false unless Setting.signals_decision_execution_enabled?
-
-
log_info("Execution start: synced_email_id=#{synced_email.id} matched=#{synced_email.matched?}")
-
builder = Signals::Decisioning::DecisionInputBuilder.new(synced_email)
-
base = builder.build_base
-
facts = facts_for_execution(builder, base)
-
decision_input =
-
if pipeline_recorder
-
pipeline_recorder.measure(
-
:decision_input_build,
-
input_payload: { "synced_email_id" => synced_email.id },
-
output_payload_override: { "matched" => synced_email.matched? }
-
) { builder.build(facts: facts) }
-
else
-
builder.build(facts: facts)
-
end
-
unless schema_valid?(DECISION_INPUT_SCHEMA_ID, decision_input)
-
errors = schema_errors(DECISION_INPUT_SCHEMA_ID, decision_input)
-
log_warning("Execution invalid DecisionInput: synced_email_id=#{synced_email.id} errors=#{errors.size}")
-
pipeline_recorder&.event!(
-
event_type: :decision_plan_schema_validate,
-
status: :failed,
-
output_payload: { "decision_input_error_count" => errors.size }
-
)
-
return persist(status: "decision_input_invalid", errors: errors)
-
end
-
-
decision_plan =
-
if pipeline_recorder
-
pipeline_recorder.measure(
-
:decision_plan_build,
-
input_payload: { "synced_email_id" => synced_email.id },
-
output_payload_override: {}
-
) do
-
Signals::Decisioning::Planner.new(decision_input).plan
-
end
-
else
-
Signals::Decisioning::Planner.new(decision_input).plan
-
end
-
unless schema_valid?(DECISION_PLAN_SCHEMA_ID, decision_plan)
-
errors = schema_errors(DECISION_PLAN_SCHEMA_ID, decision_plan)
-
log_warning("Execution invalid DecisionPlan: synced_email_id=#{synced_email.id} errors=#{errors.size}")
-
pipeline_recorder&.event!(
-
event_type: :decision_plan_schema_validate,
-
status: :failed,
-
output_payload: { "decision_plan_error_count" => errors.size }
-
)
-
return persist(status: "decision_plan_invalid", errors: errors)
-
end
-
pipeline_recorder&.event!(
-
event_type: :decision_plan_schema_validate,
-
status: :success,
-
output_payload: { "decision" => decision_plan["decision"], "steps" => decision_plan.fetch("plan", []).size }
-
)
-
-
semantic_errors =
-
if pipeline_recorder
-
pipeline_recorder.measure(
-
:decision_plan_semantic_validate,
-
input_payload: { "synced_email_id" => synced_email.id },
-
output_payload_override: lambda { |errs| { "error_count" => Array(errs).size } }
-
) { Signals::Decisioning::SemanticValidator.new(decision_input, decision_plan).errors }
-
else
-
Signals::Decisioning::SemanticValidator.new(decision_input, decision_plan).errors
-
end
-
if semantic_errors.any?
-
log_warning("Execution semantic invalid: synced_email_id=#{synced_email.id} errors=#{semantic_errors.size}")
-
return persist(status: "semantic_invalid", errors: semantic_errors)
-
end
-
-
applied =
-
if pipeline_recorder
-
pipeline_recorder.measure(
-
:execution_dispatch,
-
input_payload: { "synced_email_id" => synced_email.id },
-
output_payload_override: {}
-
) { execute_steps(decision_plan) }
-
else
-
execute_steps(decision_plan)
-
end
-
log_info("Execution executed: synced_email_id=#{synced_email.id} applied_steps=#{applied.size}")
-
persist(status: "executed", errors: [], applied: applied)
-
rescue StandardError => e
-
notify_error(
-
e,
-
context: "signals_decision_execution_runner",
-
severity: "error",
-
user: synced_email&.user,
-
synced_email_id: synced_email&.id,
-
application_id: synced_email&.interview_application_id
-
)
-
log_error("Execution exception: synced_email_id=#{synced_email&.id} #{e.class}: #{e.message}")
-
persist(status: "exception", errors: [ { "message" => e.message, "class" => e.class.name } ])
-
end
-
-
private
-
-
attr_reader :synced_email, :pipeline_recorder
-
-
def facts_for_execution(builder, base)
-
app = synced_email.interview_application
-
-
unless Setting.signals_email_facts_extraction_enabled?
-
return builder.build_fallback_facts(app)
-
end
-
-
persisted = synced_email.extracted_data.is_a?(Hash) ? synced_email.extracted_data[Signals::Facts::EmailFactsExtractor::FACTS_KEY] : nil
-
persisted_meta = synced_email.extracted_data.is_a?(Hash) ? synced_email.extracted_data[Signals::Facts::EmailFactsExtractor::FACTS_META_KEY] : nil
-
-
if persisted.is_a?(Hash) && persisted_meta.is_a?(Hash) && persisted_meta["status"] == "ok" && schema_valid?(EMAIL_FACTS_SCHEMA_ID, persisted)
-
pipeline_recorder&.event!(
-
event_type: :email_facts_extraction,
-
status: :success,
-
output_payload: { "source" => "persisted", "kind" => persisted.dig("classification", "kind") }.compact
-
)
-
return persisted
-
end
-
-
extractor = Signals::Facts::EmailFactsExtractor.new(synced_email, decision_input_base: base)
-
res =
-
if pipeline_recorder
-
pipeline_recorder.measure(
-
:email_facts_extraction,
-
input_payload: { "synced_email_id" => synced_email.id },
-
output_payload_override: lambda { |r|
-
{
-
"success" => r[:success],
-
"llm_api_log_id" => r[:llm_api_log_id],
-
"kind" => r.dig(:facts, "classification", "kind"),
-
"error" => r[:error]
-
}.compact
-
}
-
) { extractor.call }
-
else
-
extractor.call
-
end
-
res[:success] ? res[:facts] : builder.build_fallback_facts(app)
-
end
-
-
def schema_valid?(schema_id, payload)
-
schema_errors(schema_id, payload).empty?
-
end
-
-
def schema_errors(schema_id, payload)
-
Signals::Contracts::Validators::JsonSchemaValidator.new(schema_id: schema_id).errors_for(payload)
-
end
-
-
def execute_steps(plan)
-
dispatcher = Signals::Decisioning::Execution::Dispatcher.new(synced_email, pipeline_recorder: pipeline_recorder)
-
plan.fetch("plan", []).filter_map { |step| dispatcher.dispatch(step) }
-
end
-
-
def persist(status:, errors:, applied: nil)
-
existing = synced_email.extracted_data.is_a?(Hash) ? synced_email.extracted_data.deep_dup : {}
-
existing[EXECUTION_META_KEY] = {
-
"status" => status,
-
"errors" => errors,
-
"applied" => applied,
-
"executed_at" => Time.current.iso8601
-
}
-
synced_email.update!(extracted_data: existing)
-
status == "executed"
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Decisioning
-
# Deterministic baseline planner that drives decisions from EmailFacts.
-
#
-
# This planner should not rely on legacy regex classification directly; it
-
# should rely on `DecisionInput.facts` and the evidence strings within it.
-
class Planner
-
VERSION = "2026-01-27".freeze
-
-
RULES = [
-
Signals::Decisioning::Rules::OpportunityRule,
-
Signals::Decisioning::Rules::SchedulingRule,
-
Signals::Decisioning::Rules::RoundFeedbackRule,
-
Signals::Decisioning::Rules::StatusUpdateRule
-
].freeze
-
-
def initialize(decision_input)
-
@input = decision_input
-
end
-
-
def plan
-
RULES.each do |rule_class|
-
res = rule_class.new(input).call
-
next if res.nil?
-
-
if res[:decision] == "noop"
-
return noop(res[:reason])
-
end
-
-
return apply(confidence: res[:confidence], reasons: res[:reasons], steps: res[:steps])
-
end
-
-
return noop("unmatched_email") unless input.dig("match", "matched")
-
-
noop("unsupported_kind")
-
end
-
-
private
-
-
attr_reader :input
-
-
def noop(reason)
-
{
-
"version" => VERSION,
-
"decision" => "noop",
-
"confidence" => 1.0,
-
"reasons" => [ reason ],
-
"plan" => []
-
}
-
end
-
-
def apply(confidence:, reasons:, steps:)
-
{
-
"version" => VERSION,
-
"decision" => "apply",
-
"confidence" => confidence,
-
"reasons" => reasons,
-
"plan" => steps
-
}
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Decisioning
-
module Rules
-
class BaseRule
-
def initialize(input)
-
@input = input
-
end
-
-
private
-
-
attr_reader :input
-
-
def kind
-
input.dig("facts", "classification", "kind").to_s
-
end
-
-
def matched?
-
!!input.dig("match", "matched")
-
end
-
-
def app_id
-
input.dig("application", "id")
-
end
-
-
def email_id
-
input.dig("event", "synced_email_id")
-
end
-
-
def email_date
-
input.dig("event", "email_date")
-
end
-
-
def step_factory
-
@step_factory ||= Signals::Decisioning::StepFactory.new(
-
application_id: app_id,
-
synced_email_id: email_id,
-
email_date: email_date
-
)
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Decisioning
-
module Rules
-
class OpportunityRule < BaseRule
-
MIN_CLASSIFICATION_CONFIDENCE = 0.7
-
-
def call
-
return nil if matched?
-
-
k = kind
-
return nil unless %w[recruiter_outreach potential_opportunity].include?(k)
-
-
classification_conf = input.dig("facts", "classification", "confidence").to_f
-
return { decision: "noop", reason: "opportunity_low_confidence" } if classification_conf < MIN_CLASSIFICATION_CONFIDENCE
-
-
evidence = Array(input.dig("facts", "classification", "evidence")).first(3)
-
evidence = Array(input.dig("facts", "action_links")).first(1).map { |l| "job posting: #{l["url"]}" } if evidence.empty?
-
return { decision: "noop", reason: "opportunity_no_evidence" } if evidence.empty?
-
-
company_name = input.dig("facts", "entities", "company", "name")
-
recruiter_name = input.dig("facts", "entities", "recruiter", "name") || input.dig("event", "from", "name")
-
recruiter_email = input.dig("facts", "entities", "recruiter", "email") || input.dig("event", "from", "email")
-
job_title = input.dig("facts", "entities", "job", "title")
-
job_url = choose_job_url
-
-
extracted_links = build_extracted_links(job_url)
-
-
steps = [
-
{
-
"step_id" => "create_opportunity",
-
"action" => "create_opportunity",
-
"target" => step_factory.target(selector: "none").merge("application_id" => nil),
-
"params" => {
-
"company_name" => company_name,
-
"job_title" => job_title,
-
"job_url" => job_url,
-
"recruiter_name" => recruiter_name,
-
"recruiter_email" => recruiter_email,
-
"extracted_links" => extracted_links,
-
"source" => { "synced_email_id" => email_id }
-
},
-
"preconditions" => [],
-
"evidence" => evidence,
-
"risk" => "low"
-
}
-
]
-
-
if job_url.present?
-
steps.concat(job_listing_steps(job_url, company_name, job_title))
-
end
-
-
{ decision: "apply", confidence: 0.75, reasons: [ "recruiter_outreach_unmatched" ], steps: steps }
-
end
-
-
private
-
-
def choose_job_url
-
input.dig("facts", "entities", "job", "url") ||
-
Array(input.dig("facts", "action_links")).map { |l| l["url"] }.find(&:present?) ||
-
Array(input.dig("event", "links")).map { |l| l["url"] }.find(&:present?)
-
end
-
-
def build_extracted_links(job_url)
-
links = Array(input.dig("event", "links")).map do |l|
-
{
-
"url" => l["url"].to_s,
-
"type" => (l["url"].to_s == job_url.to_s ? "job_posting" : "unknown"),
-
"description" => l["label_hint"]
-
}
-
end
-
-
if links.empty? && job_url.present?
-
links = [
-
{ "url" => job_url.to_s, "type" => "job_posting", "description" => "Job posting" }
-
]
-
end
-
-
links.first(50)
-
end
-
-
def job_listing_steps(job_url, company_name, job_title)
-
[
-
{
-
"step_id" => "upsert_job_listing_from_url",
-
"action" => "upsert_job_listing_from_url",
-
"target" => step_factory.target(selector: "none").merge("application_id" => nil),
-
"params" => {
-
"url" => job_url,
-
"company_name" => company_name,
-
"job_role_title" => job_title,
-
"job_title" => job_title,
-
"source" => { "synced_email_id" => email_id }
-
},
-
"preconditions" => [],
-
"evidence" => [ "job posting: #{job_url}" ],
-
"risk" => "low"
-
},
-
{
-
"step_id" => "attach_job_listing_to_opportunity",
-
"action" => "attach_job_listing_to_opportunity",
-
"target" => step_factory.target(selector: "none").merge("application_id" => nil),
-
"params" => {
-
"url" => job_url,
-
"source" => { "synced_email_id" => email_id }
-
},
-
"preconditions" => [],
-
"evidence" => [ "job posting: #{job_url}" ],
-
"risk" => "low"
-
},
-
{
-
"step_id" => "enqueue_scrape_job_listing",
-
"action" => "enqueue_scrape_job_listing",
-
"target" => step_factory.target(selector: "none").merge("application_id" => nil),
-
"params" => {
-
"url" => job_url,
-
"force" => false,
-
"source" => { "synced_email_id" => email_id }
-
},
-
"preconditions" => [],
-
"evidence" => [ "job posting: #{job_url}" ],
-
"risk" => "low"
-
}
-
]
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Decisioning
-
module Rules
-
class RoundFeedbackRule < BaseRule
-
def call
-
return nil unless matched?
-
return nil unless kind == "round_feedback"
-
-
rf = input.dig("facts", "round_feedback") || {}
-
result = rf["result"].to_s
-
evidence = Array(rf["evidence"]).first(3)
-
return { decision: "noop", reason: "round_feedback_no_evidence" } if evidence.empty?
-
-
mapped = %w[passed failed waitlisted cancelled].include?(result) ? result : nil
-
return { decision: "noop", reason: "round_feedback_unknown_result" } unless mapped
-
-
steps = [
-
step_factory.set_round_result(
-
step_id: "set_round_result",
-
selector: "latest_pending",
-
result: mapped,
-
completed_at: input.dig("event", "email_date"),
-
preconditions: [ "application.rounds_recent.any(result==pending) == true" ],
-
evidence: evidence,
-
risk: mapped == "failed" ? "high" : "low"
-
)
-
]
-
-
if rf.dig("feedback", "has_detailed_feedback")
-
fb_text = rf.dig("feedback", "full_feedback_text").to_s
-
fb_evidence = fb_text.present? ? [ fb_text ] : evidence.first(1)
-
-
steps << step_factory.create_interview_feedback(
-
step_id: "create_interview_feedback",
-
selector: "latest_pending",
-
params: {
-
"went_well" => Array(rf.dig("feedback", "strengths")).map { |s| "• #{s}" }.join("\n").presence,
-
"to_improve" => Array(rf.dig("feedback", "improvements")).map { |s| "• #{s}" }.join("\n").presence,
-
"ai_summary" => rf.dig("feedback", "summary"),
-
"interviewer_notes" => rf.dig("feedback", "full_feedback_text"),
-
"recommended_action" => default_recommended_action(mapped, rf)
-
},
-
preconditions: [ "round.interview_feedback == null" ],
-
evidence: fb_evidence,
-
risk: "low"
-
)
-
end
-
-
{ decision: "apply", confidence: 0.7, reasons: [ "round_feedback_kind" ], steps: steps }
-
end
-
-
private
-
-
def default_recommended_action(result, round_feedback)
-
case result
-
when "passed"
-
if round_feedback.dig("next_steps", "has_next_round")
-
"Prepare for #{round_feedback.dig("next_steps", "next_round_type") || "next round"}"
-
else
-
"Follow up on next steps"
-
end
-
when "failed"
-
"Review feedback and apply learnings to future interviews"
-
when "waitlisted"
-
"Follow up in 1-2 weeks if no update"
-
else
-
nil
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Decisioning
-
module Rules
-
class SchedulingRule < BaseRule
-
def call
-
return nil unless matched?
-
return nil unless kind == "scheduling"
-
-
scheduling = input.dig("facts", "scheduling") || {}
-
evidence = Array(scheduling["evidence"]).first(3)
-
return { decision: "noop", reason: "scheduling_no_evidence" } if evidence.empty?
-
-
stage = scheduling["stage"] || "screening"
-
-
steps = [
-
step_factory.create_round(
-
step_id: "create_round_1",
-
params: {
-
"stage" => stage,
-
"stage_name" => scheduling["stage_name"],
-
"scheduled_at" => scheduling["scheduled_at"],
-
"duration_minutes" => scheduling["duration_minutes"].to_i.nonzero? || 30,
-
"interviewer_name" => scheduling["interviewer_name"],
-
"interviewer_role" => scheduling["interviewer_role"],
-
"video_link" => scheduling["video_link"],
-
"location" => scheduling["location"],
-
"phone_number" => scheduling["phone_number"],
-
"notes" => "📬 Created from email signal"
-
},
-
preconditions: [ "match.matched == true" ],
-
evidence: evidence,
-
risk: "low"
-
),
-
step_factory.set_pipeline_stage(
-
step_id: "set_pipeline_from_round",
-
selector: "latest",
-
stage: (stage == "screening" ? "screening" : "interviewing"),
-
preconditions: [ "application.pipeline_stage != closed" ],
-
evidence: evidence.first(1),
-
risk: "low"
-
)
-
]
-
-
{ decision: "apply", confidence: 0.6, reasons: [ "scheduling_kind" ], steps: steps }
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Decisioning
-
module Rules
-
class StatusUpdateRule < BaseRule
-
def call
-
return nil unless matched?
-
return nil unless kind == "status_update"
-
-
status_change = input.dig("facts", "status_change") || {}
-
type = status_change["type"].to_s
-
evidence = Array(status_change["evidence"]).first(3)
-
return { decision: "noop", reason: "status_update_no_evidence" } if evidence.empty?
-
-
case type
-
when "rejection"
-
steps = [
-
step_factory.set_application_status(
-
step_id: "set_status_rejected",
-
status: "rejected",
-
preconditions: [ "application.status == active" ],
-
evidence: evidence,
-
risk: "high"
-
),
-
step_factory.set_pipeline_stage(
-
step_id: "set_pipeline_closed",
-
selector: "none",
-
stage: "closed",
-
preconditions: [ "application.pipeline_stage != closed" ],
-
evidence: evidence.first(1),
-
risk: "high"
-
)
-
]
-
{ decision: "apply", confidence: 0.75, reasons: [ "rejection_status_change" ], steps: steps }
-
when "offer"
-
steps = [
-
step_factory.set_pipeline_stage(
-
step_id: "set_pipeline_offer",
-
selector: "none",
-
stage: "offer",
-
preconditions: [ "application.pipeline_stage != offer" ],
-
evidence: evidence,
-
risk: "medium"
-
),
-
step_factory.create_company_feedback(
-
step_id: "create_company_feedback_offer",
-
params: {
-
"feedback_type" => "offer",
-
"feedback_text" => build_offer_feedback_text(status_change),
-
"rejection_reason" => nil,
-
"next_steps" => status_change.dig("offer_details", "next_steps")
-
},
-
preconditions: [ "application.company_feedback == null" ],
-
evidence: evidence.first(2),
-
risk: "low"
-
)
-
]
-
{ decision: "apply", confidence: 0.7, reasons: [ "offer_status_change" ], steps: steps }
-
when "on_hold"
-
steps = [
-
step_factory.set_application_status(
-
step_id: "set_status_on_hold",
-
status: "on_hold",
-
preconditions: [ "application.status == active" ],
-
evidence: evidence,
-
risk: "medium"
-
)
-
]
-
{ decision: "apply", confidence: 0.65, reasons: [ "on_hold_status_change" ], steps: steps }
-
when "withdrawal"
-
steps = [
-
step_factory.set_application_status(
-
step_id: "set_status_withdrawn",
-
status: "withdrawn",
-
preconditions: [ "application.status == active" ],
-
evidence: evidence,
-
risk: "medium"
-
)
-
]
-
{ decision: "apply", confidence: 0.65, reasons: [ "withdrawal_status_change" ], steps: steps }
-
else
-
{ decision: "noop", reason: "status_update_no_change" }
-
end
-
end
-
-
private
-
-
def build_offer_feedback_text(status_change)
-
role = status_change.dig("offer_details", "role_title")
-
deadline = status_change.dig("offer_details", "response_deadline")
-
start = status_change.dig("offer_details", "start_date")
-
parts = []
-
parts << "Offer received#{role ? " for #{role}" : ""}."
-
parts << "Respond by: #{deadline}" if deadline.present?
-
parts << "Start date: #{start}" if start.present?
-
parts.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Decisioning
-
# Semantic (non-schema) validation for DecisionPlan.
-
#
-
# These checks are used as guardrails before execution.
-
class SemanticValidator
-
def initialize(decision_input, decision_plan)
-
@input = decision_input
-
@plan = decision_plan
-
end
-
-
# @return [Array<Hash>] list of error hashes
-
def errors
-
errs = []
-
body = input.dig("event", "body", "text").to_s
-
body_norm = normalize_text(body)
-
body_alnum = normalize_alnum(body)
-
-
plan.fetch("plan", []).each do |step|
-
evidence = Array(step["evidence"])
-
next if step["action"] == "noop"
-
if evidence.empty?
-
errs << { "type" => "missing_evidence", "step_id" => step["step_id"] }
-
next
-
end
-
evidence.each do |ev|
-
next if ev.to_s.strip.empty?
-
ev_text = ev.to_s
-
-
urls = extract_urls(ev_text)
-
if urls.any?
-
urls.each do |url|
-
next if url.strip.empty?
-
# Allow trailing punctuation/brackets and minor formatting differences.
-
unless body.match?(/#{Regexp.escape(url)}[\]\)\}\.,:;!?"]?/i)
-
errs << { "type" => "evidence_not_in_body", "step_id" => step["step_id"], "evidence" => ev_text }
-
break
-
end
-
end
-
next
-
end
-
-
ev_norm = normalize_text(ev_text)
-
ev_alnum = normalize_alnum(ev_text)
-
unless body_norm.include?(ev_norm) || body_alnum.include?(ev_alnum)
-
errs << { "type" => "evidence_not_in_body", "step_id" => step["step_id"], "evidence" => ev_text }
-
end
-
end
-
end
-
-
errs
-
end
-
-
def valid?
-
errors.empty?
-
end
-
-
private
-
-
attr_reader :input, :plan
-
-
def normalize_text(text)
-
text.to_s.downcase.gsub(/\s+/, " ").strip
-
end
-
-
def normalize_alnum(text)
-
text.to_s.downcase.gsub(/[^a-z0-9]+/, " ").gsub(/\s+/, " ").strip
-
end
-
-
def extract_urls(text)
-
URI.extract(text.to_s, %w[http https])
-
rescue StandardError
-
[]
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Decisioning
-
# Shadow mode runner for the new Facts → Decision contracts.
-
#
-
# This does NOT execute any plan. It only:
-
# - builds DecisionInput
-
# - generates a DecisionPlan (currently deterministic baseline)
-
# - validates both shapes against schemas
-
# - persists them onto SyncedEmail.extracted_data under versioned keys
-
class ShadowRunner < ApplicationService
-
DECISION_INPUT_SCHEMA_ID = "gleania://signals/contracts/schemas/decision_input.schema.json"
-
DECISION_PLAN_SCHEMA_ID = "gleania://signals/contracts/schemas/decision_plan.schema.json"
-
-
DECISION_INPUT_KEY = "decision_input_v1"
-
DECISION_PLAN_KEY = "decision_plan_v1"
-
DECISION_META_KEY = "decisioning_meta_v1"
-
-
def initialize(synced_email, pipeline_run: nil)
-
@synced_email = synced_email
-
@pipeline_recorder = Signals::Observability::EmailPipelineRecorder.for_run(pipeline_run)
-
end
-
-
def call
-
log_info("Shadow decisioning start: synced_email_id=#{synced_email.id} matched=#{synced_email.matched?}")
-
builder = Signals::Decisioning::DecisionInputBuilder.new(synced_email)
-
base = builder.build_base
-
-
facts =
-
if Setting.signals_email_facts_extraction_enabled?
-
extractor = Signals::Facts::EmailFactsExtractor.new(synced_email, decision_input_base: base)
-
res =
-
if pipeline_recorder
-
pipeline_recorder.measure(
-
:email_facts_extraction,
-
input_payload: { "synced_email_id" => synced_email.id },
-
output_payload_override: lambda { |r|
-
{
-
"success" => r[:success],
-
"llm_api_log_id" => r[:llm_api_log_id],
-
"kind" => r.dig(:facts, "classification", "kind"),
-
"error" => r[:error]
-
}.compact
-
}
-
) { extractor.call }
-
else
-
extractor.call
-
end
-
-
res[:success] ? res[:facts] : builder.build_fallback_facts(synced_email.interview_application)
-
else
-
builder.build_fallback_facts(synced_email.interview_application)
-
end
-
-
decision_input =
-
if pipeline_recorder
-
pipeline_recorder.measure(
-
:decision_input_build,
-
input_payload: { "synced_email_id" => synced_email.id },
-
output_payload_override: { "matched" => synced_email.matched? }
-
) { builder.build(facts: facts) }
-
else
-
builder.build(facts: facts)
-
end
-
input_errors = schema_validator(DECISION_INPUT_SCHEMA_ID).errors_for(decision_input)
-
if input_errors.any?
-
log_warning("Shadow decisioning invalid DecisionInput: synced_email_id=#{synced_email.id} errors=#{input_errors.size}")
-
pipeline_recorder&.event!(
-
event_type: :decision_plan_schema_validate,
-
status: :failed,
-
output_payload: { "decision_input_error_count" => input_errors.size }
-
)
-
return persist_errors("decision_input_invalid", input_errors)
-
end
-
-
decision_plan =
-
if pipeline_recorder
-
pipeline_recorder.measure(
-
:decision_plan_build,
-
input_payload: { "synced_email_id" => synced_email.id },
-
output_payload_override: {}
-
) do
-
Signals::Decisioning::Planner.new(decision_input).plan
-
end
-
else
-
Signals::Decisioning::Planner.new(decision_input).plan
-
end
-
plan_errors = schema_validator(DECISION_PLAN_SCHEMA_ID).errors_for(decision_plan)
-
if plan_errors.any?
-
log_warning("Shadow decisioning invalid DecisionPlan: synced_email_id=#{synced_email.id} errors=#{plan_errors.size}")
-
pipeline_recorder&.event!(
-
event_type: :decision_plan_schema_validate,
-
status: :failed,
-
output_payload: { "decision_plan_error_count" => plan_errors.size }
-
)
-
return persist_errors("decision_plan_invalid", plan_errors)
-
end
-
pipeline_recorder&.event!(
-
event_type: :decision_plan_schema_validate,
-
status: :success,
-
output_payload: { "decision" => decision_plan["decision"], "steps" => decision_plan.fetch("plan", []).size }
-
)
-
-
persist_payloads(decision_input, decision_plan, status: "ok", errors: [])
-
log_info("Shadow decisioning ok: synced_email_id=#{synced_email.id} decision=#{decision_plan["decision"]}")
-
rescue StandardError => e
-
notify_error(
-
e,
-
context: "signals_decision_shadow_runner",
-
severity: "warning",
-
user: synced_email&.user,
-
synced_email_id: synced_email&.id,
-
application_id: synced_email&.interview_application_id
-
)
-
log_error("Shadow decisioning exception: synced_email_id=#{synced_email&.id} #{e.class}: #{e.message}")
-
persist_errors("exception", [ { "message" => e.message, "class" => e.class.name } ])
-
end
-
-
private
-
-
attr_reader :synced_email, :pipeline_recorder
-
-
def schema_validator(schema_id)
-
Signals::Contracts::Validators::JsonSchemaValidator.new(schema_id: schema_id)
-
end
-
-
def persist_payloads(decision_input, decision_plan, status:, errors:)
-
existing = synced_email.extracted_data.is_a?(Hash) ? synced_email.extracted_data.deep_dup : {}
-
now = Time.current.iso8601
-
-
existing[DECISION_INPUT_KEY] = decision_input
-
existing[DECISION_PLAN_KEY] = decision_plan
-
existing[DECISION_META_KEY] = {
-
"status" => status,
-
"errors" => errors,
-
"generated_at" => now
-
}
-
-
synced_email.update!(extracted_data: existing)
-
true
-
end
-
-
def persist_errors(status, errors)
-
persist_payloads(nil, nil, status: status, errors: errors)
-
false
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Decisioning
-
# Helper for consistent DecisionPlan step construction.
-
class StepFactory
-
def initialize(application_id:, synced_email_id:, email_date: nil)
-
@application_id = application_id
-
@synced_email_id = synced_email_id
-
@email_date = email_date
-
end
-
-
def target(selector:)
-
{
-
"application_id" => application_id,
-
"round" => {
-
"selector" => selector,
-
"id" => nil,
-
"scheduled_at" => nil,
-
"window_minutes" => 0,
-
"stage" => nil,
-
"result" => nil
-
}
-
}
-
end
-
-
def step(step_id:, action:, target:, params: {}, preconditions: [], evidence: [], risk: "low", include_source: false)
-
merged_params = params || {}
-
merged_params = merged_params.merge("source" => { "synced_email_id" => synced_email_id }) if include_source
-
-
{
-
"step_id" => step_id,
-
"action" => action,
-
"target" => target,
-
"params" => merged_params,
-
"preconditions" => Array(preconditions),
-
"evidence" => Array(evidence),
-
"risk" => risk.to_s
-
}
-
end
-
-
def create_round(step_id:, params:, preconditions:, evidence:, risk: "low")
-
step(
-
step_id: step_id,
-
action: "create_round",
-
target: target(selector: "none"),
-
params: params,
-
preconditions: preconditions,
-
evidence: evidence,
-
risk: risk,
-
include_source: true
-
)
-
end
-
-
def set_pipeline_stage(step_id:, selector:, stage:, preconditions:, evidence:, risk: "low")
-
step(
-
step_id: step_id,
-
action: "set_pipeline_stage",
-
target: target(selector: selector),
-
params: { "stage" => stage },
-
preconditions: preconditions,
-
evidence: evidence,
-
risk: risk
-
)
-
end
-
-
def set_application_status(step_id:, status:, preconditions:, evidence:, risk: "low")
-
step(
-
step_id: step_id,
-
action: "set_application_status",
-
target: target(selector: "none"),
-
params: { "status" => status },
-
preconditions: preconditions,
-
evidence: evidence,
-
risk: risk
-
)
-
end
-
-
def set_round_result(step_id:, selector:, result:, completed_at:, preconditions:, evidence:, risk: "low")
-
step(
-
step_id: step_id,
-
action: "set_round_result",
-
target: target(selector: selector),
-
params: { "result" => result, "completed_at" => completed_at || email_date },
-
preconditions: preconditions,
-
evidence: evidence,
-
risk: risk,
-
include_source: true
-
)
-
end
-
-
def create_interview_feedback(step_id:, selector:, params:, preconditions:, evidence:, risk: "low")
-
step(
-
step_id: step_id,
-
action: "create_interview_feedback",
-
target: target(selector: selector),
-
params: params.merge("round_selector" => selector),
-
preconditions: preconditions,
-
evidence: evidence,
-
risk: risk,
-
include_source: true
-
)
-
end
-
-
def create_company_feedback(step_id:, params:, preconditions:, evidence:, risk: "low")
-
step(
-
step_id: step_id,
-
action: "create_company_feedback",
-
target: target(selector: "none"),
-
params: params,
-
preconditions: preconditions,
-
evidence: evidence,
-
risk: risk,
-
include_source: true
-
)
-
end
-
-
private
-
-
attr_reader :application_id, :synced_email_id, :email_date
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Facts
-
# Builds the canonical email event payload for DecisionInput.
-
#
-
# Goal: ensure every downstream component (facts extraction, planner, semantic validation)
-
# sees the *same* canonical email text.
-
class CanonicalEmailEventBuilder
-
REPLY_SEPARATORS = [
-
/^On .+ wrote:$/i,
-
/^On .+sent:$/i,
-
/^On .+wrote$/i,
-
/^From:\s+/i,
-
/^Sent:\s+/i,
-
/^To:\s+/i,
-
/^Subject:\s+/i,
-
/^-----Original Message-----/i,
-
/^----- Forwarded message -----/i,
-
/^Begin forwarded message:/i
-
].freeze
-
-
URL_REGEX = %r{https?://[^\s<>"')]+}i
-
-
def initialize(synced_email)
-
@synced_email = synced_email
-
end
-
-
def build
-
raw_text, source = best_body_source
-
canonical = canonicalize_text(raw_text)
-
links = extract_links(canonical)
-
-
{
-
"event_type" => "email",
-
"synced_email_id" => synced_email.id,
-
"thread_id" => synced_email.thread_id,
-
"received_at" => synced_email.email_date&.iso8601,
-
"email_date" => synced_email.email_date&.iso8601,
-
"from" => {
-
"email" => synced_email.from_email,
-
"name" => synced_email.from_name
-
},
-
"to" => [],
-
"subject" => synced_email.subject,
-
"body" => {
-
"text" => canonical,
-
"source" => source,
-
"truncated" => false,
-
"normalization" => {
-
"replies_removed" => true,
-
"html_stripped" => source == "body_html",
-
"whitespace_collapsed" => true
-
}
-
},
-
"links" => links
-
}
-
end
-
-
private
-
-
attr_reader :synced_email
-
-
def best_body_source
-
if synced_email.body_preview.present?
-
[ synced_email.body_preview.to_s, "body_preview" ]
-
elsif synced_email.body_html.present?
-
[ ActionController::Base.helpers.strip_tags(synced_email.body_html.to_s), "body_html" ]
-
else
-
[ synced_email.snippet.to_s, "snippet" ]
-
end
-
end
-
-
def canonicalize_text(text)
-
return "" if text.blank?
-
-
normalized = text.to_s.gsub(/\r\n?/, "\n")
-
lines = normalized.split("\n")
-
cutoff = lines.index { |line| REPLY_SEPARATORS.any? { |rx| line.to_s.strip.match?(rx) } }
-
kept = cutoff ? lines[0...cutoff] : lines
-
kept = kept.reject { |line| line.lstrip.start_with?(">") }
-
kept.join("\n").gsub(/\s+/, " ").strip
-
end
-
-
def extract_links(text)
-
text.to_s.scan(URL_REGEX).uniq.first(50).map do |url|
-
{ "url" => url, "label_hint" => nil }
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Facts
-
# Extracts EmailFacts using an LLM and validates against the EmailFacts schema.
-
#
-
# Persists results onto synced_email.extracted_data under versioned keys.
-
class EmailFactsExtractor < ApplicationService
-
EMAIL_FACTS_SCHEMA_ID = "gleania://signals/contracts/schemas/components/facts/email_facts.schema.json"
-
OPERATION_TYPE = :email_facts_extraction
-
-
FACTS_KEY = "email_facts_v1"
-
FACTS_META_KEY = "email_facts_meta_v1"
-
-
def initialize(synced_email, decision_input_base:)
-
@synced_email = synced_email
-
@decision_input_base = decision_input_base
-
end
-
-
def call
-
log_info("EmailFacts extraction start: synced_email_id=#{synced_email.id}")
-
-
prompt = build_prompt
-
prompt_template = Ai::EmailFactsExtractionPrompt.active_prompt
-
system_message = prompt_template&.system_prompt.presence || Ai::EmailFactsExtractionPrompt.default_system_prompt
-
-
runner = Ai::ProviderRunnerService.new(
-
provider_chain: provider_chain,
-
prompt: prompt,
-
content_size: prompt.bytesize,
-
system_message: system_message,
-
provider_for: method(:get_provider_instance),
-
run_options: { max_tokens: 2500, temperature: 0.1 },
-
logger_builder: lambda { |provider_name, provider|
-
Ai::ApiLoggerService.new(
-
operation_type: OPERATION_TYPE,
-
loggable: synced_email,
-
provider: provider_name,
-
model: provider.respond_to?(:model_name) ? provider.model_name : "unknown",
-
llm_prompt: prompt_template
-
)
-
},
-
operation: OPERATION_TYPE,
-
loggable: synced_email,
-
user: synced_email&.user,
-
error_context: {
-
severity: "warning",
-
synced_email_id: synced_email.id,
-
application_id: synced_email.interview_application_id
-
}
-
)
-
-
result = runner.run do |response|
-
parsed = parse_response(response[:content]) || {}
-
schema_errors = schema_validator.errors_for(parsed)
-
-
# We accept only if schema-valid.
-
accept = schema_errors.empty?
-
log_data = {
-
schema_valid: accept,
-
schema_error_count: schema_errors.size,
-
classification_kind: parsed.dig("classification", "kind"),
-
confidence: parsed.dig("extraction", "confidence")
-
}.compact
-
[ parsed, log_data, accept ]
-
end
-
-
unless result[:success]
-
log_warning("EmailFacts extraction failed: synced_email_id=#{synced_email.id} error=#{result[:error]}")
-
persist_meta(status: "failed", errors: [ { "message" => result[:error] } ])
-
return { success: false, error: result[:error] }
-
end
-
-
facts = result[:parsed]
-
persist_facts!(
-
facts,
-
meta: {
-
"status" => "ok",
-
"provider" => result[:provider],
-
"model" => result[:model],
-
"llm_api_log_id" => result[:llm_api_log_id],
-
"latency_ms" => result[:latency_ms],
-
"generated_at" => Time.current.iso8601
-
}
-
)
-
-
log_info("EmailFacts extraction ok: synced_email_id=#{synced_email.id} kind=#{facts.dig("classification", "kind")}")
-
{ success: true, facts: facts, llm_api_log_id: result[:llm_api_log_id] }
-
rescue StandardError => e
-
notify_error(
-
e,
-
context: "signals_email_facts_extractor",
-
severity: "warning",
-
user: synced_email&.user,
-
synced_email_id: synced_email&.id,
-
application_id: synced_email&.interview_application_id
-
)
-
log_error("EmailFacts extraction exception: synced_email_id=#{synced_email&.id} #{e.class}: #{e.message}")
-
persist_meta(status: "exception", errors: [ { "message" => e.message, "class" => e.class.name } ])
-
{ success: false, error: e.message }
-
end
-
-
private
-
-
attr_reader :synced_email, :decision_input_base
-
-
def schema_validator
-
@schema_validator ||= Signals::Contracts::Validators::JsonSchemaValidator.new(schema_id: EMAIL_FACTS_SCHEMA_ID)
-
end
-
-
def build_prompt
-
event = decision_input_base.fetch("event")
-
app_snapshot = decision_input_base["application"]
-
vars = {
-
subject: event["subject"].to_s,
-
body: event.dig("body", "text").to_s,
-
from_email: event.dig("from", "email").to_s,
-
from_name: event.dig("from", "name").to_s,
-
email_type: synced_email.email_type.to_s,
-
application_snapshot: app_snapshot ? JSON.pretty_generate(app_snapshot) : "null"
-
}
-
-
Ai::PromptBuilderService.new(
-
prompt_class: Ai::EmailFactsExtractionPrompt,
-
variables: vars
-
).run
-
end
-
-
def parse_response(content)
-
Ai::ResponseParserService.new(content).parse(symbolize: false)
-
end
-
-
def provider_chain
-
LlmProviders::ProviderConfigHelper.all_providers
-
end
-
-
def get_provider_instance(provider_name)
-
case provider_name.to_s.downcase
-
when "openai" then LlmProviders::OpenaiProvider.new
-
when "anthropic" then LlmProviders::AnthropicProvider.new
-
when "ollama" then LlmProviders::OllamaProvider.new
-
else nil
-
end
-
end
-
-
def persist_meta(status:, errors:)
-
persist_facts!(nil, meta: { "status" => status, "errors" => errors, "generated_at" => Time.current.iso8601 })
-
end
-
-
def persist_facts!(facts, meta:)
-
existing = synced_email.extracted_data.is_a?(Hash) ? synced_email.extracted_data.deep_dup : {}
-
existing[FACTS_KEY] = facts
-
existing[FACTS_META_KEY] = meta
-
synced_email.update!(extracted_data: existing)
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Observability
-
# Best-effort recorder for end-to-end email pipeline runs/events.
-
#
-
# Mirrors the ScrapingAttempt/ScrapingEvent pattern, but for the Gmail → Signals pipeline.
-
class EmailPipelineRecorder < ApplicationService
-
def self.start_for(synced_email:, user:, connected_account:, trigger:, mode:, metadata: {})
-
run = Signals::EmailPipelineRun.create!(
-
synced_email: synced_email,
-
user: user,
-
connected_account: connected_account,
-
status: :started,
-
trigger: trigger.to_s,
-
mode: mode.to_s,
-
started_at: Time.current,
-
metadata: metadata || {}
-
)
-
new(run)
-
end
-
-
def self.for_run(run)
-
return nil unless run
-
new(run)
-
end
-
-
def initialize(run)
-
@run = run
-
end
-
-
attr_reader :run
-
-
# Records a simple point-in-time event.
-
def event!(event_type:, status:, input_payload: {}, output_payload: {}, error: nil, metadata: {})
-
step_order = run.next_step_order
-
now = Time.current
-
-
Signals::EmailPipelineEvent.create!(
-
run: run,
-
synced_email: run.synced_email,
-
interview_application: run.synced_email.interview_application,
-
step_order: step_order,
-
event_type: event_type.to_s,
-
status: status.to_s,
-
started_at: now,
-
completed_at: now,
-
duration_ms: 0,
-
input_payload: input_payload || {},
-
output_payload: output_payload || {},
-
error_type: error&.class&.name,
-
error_message: error&.message,
-
metadata: metadata || {}
-
)
-
rescue StandardError => e
-
log_warning("EmailPipelineRecorder event failed: run_id=#{run&.id} #{e.class}: #{e.message}")
-
nil
-
end
-
-
# Measures a step event around a block, capturing duration and status.
-
#
-
# If the block returns a Hash, it is stored as output_payload. Otherwise it is stored as:
-
# { \"result\" => <returned value> }.
-
# @param output_payload_override [Hash, Proc, nil]
-
# - Hash: stored as output_payload
-
# - Proc: called with the block result, stored output
-
def measure(event_type, input_payload: {}, output_payload_override: nil, metadata: {})
-
step_order = run.next_step_order
-
started_at = Time.current
-
start_monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
-
-
event = Signals::EmailPipelineEvent.create!(
-
run: run,
-
synced_email: run.synced_email,
-
interview_application: run.synced_email.interview_application,
-
step_order: step_order,
-
event_type: event_type.to_s,
-
status: :started,
-
started_at: started_at,
-
input_payload: input_payload || {},
-
output_payload: {},
-
metadata: metadata || {}
-
)
-
-
result = yield
-
-
completed_at = Time.current
-
end_monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
-
duration_ms = ((end_monotonic - start_monotonic) * 1000).round
-
-
output_payload =
-
if output_payload_override.respond_to?(:call)
-
output_payload_override.call(result)
-
else
-
output_payload_override
-
end
-
output_payload ||= (result.is_a?(Hash) ? result : { "result" => result })
-
event.update!(
-
status: :success,
-
completed_at: completed_at,
-
duration_ms: duration_ms,
-
output_payload: output_payload || {}
-
)
-
-
result
-
rescue StandardError => e
-
completed_at = Time.current
-
duration_ms =
-
begin
-
end_monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
-
((end_monotonic - start_monotonic) * 1000).round
-
rescue StandardError
-
nil
-
end
-
-
begin
-
event&.update!(
-
status: :failed,
-
completed_at: completed_at,
-
duration_ms: duration_ms,
-
error_type: e.class.name,
-
error_message: e.message,
-
output_payload: { "error" => e.message }
-
)
-
rescue StandardError => update_err
-
log_warning("EmailPipelineRecorder failed to update event: run_id=#{run&.id} #{update_err.class}: #{update_err.message}")
-
end
-
-
raise
-
end
-
-
def finish_success!(metadata: {})
-
finish!(status: :success, metadata: metadata)
-
end
-
-
def finish_failed!(exception, metadata: {})
-
finish!(
-
status: :failed,
-
error_type: exception.class.name,
-
error_message: exception.message,
-
metadata: metadata
-
)
-
end
-
-
def finish!(status:, error_type: nil, error_message: nil, metadata: {})
-
completed_at = Time.current
-
duration_ms = ((completed_at - run.started_at) * 1000).round if run.started_at
-
-
merged = (run.metadata.is_a?(Hash) ? run.metadata.deep_dup : {})
-
merged.merge!(metadata || {})
-
-
run.update!(
-
status: status,
-
completed_at: completed_at,
-
duration_ms: duration_ms,
-
error_type: error_type,
-
error_message: error_message,
-
metadata: merged
-
)
-
rescue StandardError => e
-
log_warning("EmailPipelineRecorder finish failed: run_id=#{run&.id} #{e.class}: #{e.message}")
-
nil
-
end
-
end
-
end
-
end
-
1
module ApplicationHelper
-
# Returns a time-based greeting message
-
#
-
# @return [String] Greeting based on current time
-
1
def greeting_message
-
hour = Time.current.hour
-
when: 0
case hour
-
when: 0
when 5..11 then "Good morning"
-
when: 0
when 12..16 then "Good afternoon"
-
else: 0
when 17..20 then "Good evening"
-
else "Hello"
-
end
-
end
-
-
# Returns the color class for an email type indicator bar
-
#
-
# @param email_type [String] The email type
-
# @return [String] Tailwind CSS classes for the color
-
1
def email_type_color_class(email_type)
-
then: 0
else: 0
case email_type&.to_s
-
when: 0
when "offer"
-
"bg-emerald-500"
-
when: 0
when "rejection"
-
"bg-red-500"
-
when: 0
when "interview_invite"
-
"bg-blue-500"
-
when: 0
when "follow_up"
-
"bg-amber-500"
-
when: 0
when "confirmation", "application_confirmation"
-
"bg-purple-500"
-
when: 0
when "scheduling"
-
"bg-cyan-500"
-
else: 0
else
-
"bg-gray-300 dark:bg-gray-600"
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# Helper methods for assistant chat views.
-
1
module AssistantHelper
-
# Renders markdown content with syntax highlighting for assistant messages.
-
# User messages are returned as plain text for simplicity.
-
#
-
# @param message [Assistant::ChatMessage] The chat message
-
# @return [String] Safe HTML string
-
1
def render_chat_message(message)
-
content = message.content.to_s
-
-
then: 0
if message.role == "assistant"
-
render_assistant_markdown(content)
-
else
-
else: 0
# User messages: simple HTML escape with line breaks
-
simple_format(h(content), {}, wrapper_tag: "span")
-
end
-
end
-
-
# Renders markdown to HTML with syntax highlighting for assistant responses.
-
#
-
# @param text [String] The markdown text
-
# @return [String] Safe HTML string
-
1
def render_assistant_markdown(text)
-
then: 0
else: 0
return "" if text.blank?
-
-
# Use the existing MarkdownRenderer service
-
html = MarkdownRenderer.render(text)
-
-
# Wrap in a container with chat-specific prose styling
-
content_tag(:div, html, class: "chat-prose")
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module Billing
-
# View helpers for checking entitlements in ERB templates.
-
1
module EntitlementsHelper
-
# @param feature_key [String, Symbol]
-
# @return [Boolean]
-
1
def entitled?(feature_key)
-
current_entitlements.allowed?(feature_key)
-
end
-
-
# @param feature_key [String, Symbol]
-
# @return [Integer, nil]
-
1
def entitlement_remaining(feature_key)
-
current_entitlements.remaining(feature_key)
-
end
-
-
# @return [Boolean]
-
1
def insight_trial_active?
-
current_entitlements.insight_trial_active?
-
end
-
-
# @return [String, nil]
-
1
def insight_trial_time_remaining_in_words
-
current_entitlements.insight_trial_time_remaining_in_words
-
end
-
-
# @return [Symbol]
-
1
def subscription_status
-
current_entitlements.subscription_status
-
end
-
-
# @return [Billing::Plan, nil]
-
1
def current_plan
-
current_entitlements.plan
-
end
-
-
1
private
-
-
1
def current_entitlements
-
@current_entitlements ||= Billing::Entitlements.for(Current.user)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module Internal
-
1
module Developer
-
# Helper methods for the developer portal
-
1
module BaseHelper
-
1
include Pagy::Frontend
-
1
include Internal::Developer::CustomRenderersHelper
-
# Returns the color scheme for a portal
-
#
-
# @param portal_key [Symbol] Portal identifier
-
# @return [String]
-
1
def portal_color(portal_key)
-
when: 0
case portal_key
-
when: 0
when :ops then "amber"
-
when: 0
when :ai then "cyan"
-
when: 0
when :assistant then "violet"
-
else: 0
when :email then "emerald"
-
else "slate"
-
end
-
end
-
-
# Returns the icon SVG for a portal
-
#
-
# @param portal_key [Symbol] Portal identifier
-
# @return [String] HTML safe SVG icon
-
1
def portal_icon(portal_key)
-
case portal_key
-
when: 0
when :ops
-
'<svg class="w-3 h-3 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>'.html_safe
-
when: 0
when :ai
-
'<svg class="w-3 h-3 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/></svg>'.html_safe
-
when: 0
when :assistant
-
'<svg class="w-3 h-3 text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>'.html_safe
-
when: 0
when :email
-
'<svg class="w-3 h-3 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h6m-6 4h10M5 6a2 2 0 012-2h10a2 2 0 012 2v12a2 2 0 01-2 2H7a2 2 0 01-2-2V6z"/></svg>'.html_safe
-
else: 0
else
-
'<svg class="w-3 h-3 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/></svg>'.html_safe
-
end
-
end
-
-
# Renders a column value from a record
-
#
-
# @param record [ActiveRecord::Base] The record
-
# @param column [ColumnDefinition] Column definition
-
# @return [String]
-
1
def render_column_value(record, column)
-
# Handle toggle columns specially
-
then: 0
if column.type == :toggle
-
field = column.toggle_field || column.name
-
render partial: "internal/developer/shared/toggle_cell",
-
else: 0
locals: { record: record, field: field }
-
then: 0
elsif column.type == :label
-
then: 0
else: 0
value = column.content.is_a?(Proc) ? column.content.call(record) : (record.public_send(column.name) rescue nil)
-
else: 0
render_label_badge(value, color: column.label_color, size: column.label_size, record: record)
-
then: 0
elsif column.content.is_a?(Proc)
-
column.content.call(record)
-
else: 0
else
-
record.public_send(column.name) rescue "—"
-
end
-
end
-
-
# Formats a value for display on show pages
-
#
-
# @param record [ActiveRecord::Base] The record
-
# @param field_name [Symbol, String] Field name
-
# @return [String] HTML safe formatted value
-
1
def format_show_value(record, field_name)
-
value = record.public_send(field_name) rescue nil
-
-
# Handle Active Storage attachments
-
then: 0
if value.is_a?(ActiveStorage::Attached::One)
-
else: 0
return render_attachment_preview(value)
-
then: 0
else: 0
elsif value.is_a?(ActiveStorage::Attached::Many)
-
return render_attachments_preview(value)
-
end
-
-
case value
-
when: 0
when nil
-
content_tag(:span, "—", class: "text-slate-400")
-
when: 0
when true
-
content_tag(:span, class: "inline-flex items-center gap-1") do
-
svg = '<svg class="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>'.html_safe
-
concat(svg)
-
concat(content_tag(:span, "Yes", class: "text-green-600 dark:text-green-400 font-medium"))
-
end
-
when: 0
when false
-
content_tag(:span, class: "inline-flex items-center gap-1") do
-
svg = '<svg class="w-4 h-4 text-slate-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>'.html_safe
-
concat(svg)
-
concat(content_tag(:span, "No", class: "text-slate-500"))
-
end
-
when: 0
when Time, DateTime
-
content_tag(:span, class: "inline-flex items-center gap-2") do
-
concat(content_tag(:span, value.strftime("%B %d, %Y at %H:%M"), class: "font-medium"))
-
concat(content_tag(:span, "(#{time_ago_in_words(value)} ago)", class: "text-slate-500 dark:text-slate-400 text-xs"))
-
end
-
when: 0
when Date
-
value.strftime("%B %d, %Y")
-
when: 0
when ActiveRecord::Base
-
then: 0
else: 0
link_text = value.respond_to?(:name) ? value.name : "#{value.class.name} ##{value.id}"
-
content_tag(:span, link_text, class: "text-indigo-600 dark:text-indigo-400")
-
when: 0
when Hash
-
render_json_block(value)
-
when: 0
when Array
-
then: 0
if value.empty?
-
else: 0
content_tag(:span, "Empty array", class: "text-slate-400 italic")
-
then: 0
elsif value.first.is_a?(Hash)
-
render_json_block(value)
-
else: 0
else
-
content_tag(:div, class: "flex flex-wrap gap-1") do
-
value.each do |item|
-
concat(content_tag(:span, item.to_s, class: "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300"))
-
end
-
end
-
end
-
when: 0
when Integer, Float, BigDecimal
-
content_tag(:span, number_with_delimiter(value), class: "font-mono")
-
else: 0
else
-
value_str = value.to_s
-
-
# Check if it looks like JSON
-
then: 0
if value_str.start_with?("{", "[") && value_str.length > 10
-
begin
-
parsed = JSON.parse(value_str)
-
render_json_block(parsed)
-
rescue JSON::ParserError
-
render_text_block(value_str)
-
end
-
else: 0
# Check if it's multi-line or long text (likely code/template)
-
then: 0
elsif value_str.include?("\n") || value_str.length > 200
-
render_text_block(value_str, detect_language(field_name, value_str))
-
else
-
else: 0
# Regular text
-
value_str
-
end
-
end
-
end
-
-
# Renders an Active Storage attachment preview
-
#
-
# @param attachment [ActiveStorage::Attached::One] The attachment
-
# @return [String] HTML safe attachment preview
-
1
def render_attachment_preview(attachment)
-
else: 0
then: 0
return content_tag(:span, "—", class: "text-slate-400") unless attachment.attached?
-
-
blob = attachment.blob
-
-
then: 0
if blob.image?
-
content_tag(:div, class: "space-y-2") do
-
concat(content_tag(:div, class: "inline-block rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700") do
-
image_tag(attachment.variant(resize_to_limit: [ 600, 400 ]),
-
class: "max-w-full h-auto max-h-64 object-contain",
-
alt: blob.filename.to_s)
-
end)
-
concat(content_tag(:div, class: "flex items-center gap-3 text-sm text-slate-500 dark:text-slate-400") do
-
concat(content_tag(:span, blob.filename.to_s, class: "font-medium text-slate-700 dark:text-slate-300"))
-
concat(content_tag(:span, "•"))
-
concat(content_tag(:span, number_to_human_size(blob.byte_size)))
-
concat(content_tag(:span, "•"))
-
concat(link_to("View full size", rails_blob_path(blob, disposition: :inline),
-
target: "_blank",
-
class: "text-indigo-600 dark:text-indigo-400 hover:underline"))
-
end)
-
end
-
else: 0
else
-
content_tag(:div, class: "flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700") do
-
concat(content_tag(:div, class: "flex-shrink-0 w-10 h-10 bg-slate-200 dark:bg-slate-700 rounded-lg flex items-center justify-center") do
-
'<svg class="w-5 h-5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>'.html_safe
-
end)
-
concat(content_tag(:div, class: "flex-1 min-w-0") do
-
concat(content_tag(:p, blob.filename.to_s, class: "font-medium text-slate-700 dark:text-slate-300 truncate"))
-
concat(content_tag(:p, number_to_human_size(blob.byte_size), class: "text-sm text-slate-500 dark:text-slate-400"))
-
end)
-
concat(link_to("Download", rails_blob_path(blob, disposition: :attachment),
-
class: "flex-shrink-0 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-lg transition-colors"))
-
end
-
end
-
end
-
-
# Renders multiple Active Storage attachments preview
-
#
-
# @param attachments [ActiveStorage::Attached::Many] The attachments
-
# @return [String] HTML safe attachments preview
-
1
def render_attachments_preview(attachments)
-
else: 0
then: 0
return content_tag(:span, "—", class: "text-slate-400") unless attachments.attached?
-
-
content_tag(:div, class: "grid grid-cols-2 md:grid-cols-3 gap-4") do
-
attachments.each do |attachment|
-
concat(render_attachment_preview(attachment))
-
end
-
end
-
end
-
-
# Renders a JSON block with syntax highlighting
-
#
-
# @param data [Hash, Array] The data to render
-
# @return [String] HTML safe JSON block
-
1
def render_json_block(data)
-
json_str = JSON.pretty_generate(data)
-
-
content_tag(:div, class: "relative group") do
-
concat(content_tag(:div, class: "absolute top-2 right-2 flex items-center gap-2") do
-
concat(content_tag(:span, "JSON", class: "text-xs font-medium text-slate-400 dark:text-slate-500 uppercase tracking-wider"))
-
concat(content_tag(:button,
-
'<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>'.html_safe,
-
type: "button",
-
class: "p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 opacity-0 group-hover:opacity-100 transition-opacity",
-
data: { controller: "clipboard", action: "click->clipboard#copy", clipboard_text_value: json_str },
-
title: "Copy to clipboard"))
-
end)
-
-
concat(content_tag(:pre, class: "bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm font-mono max-h-96 overflow-y-auto") do
-
content_tag(:code, class: "language-json") do
-
highlight_json(json_str)
-
end
-
end)
-
end
-
end
-
-
# Renders a text/code block
-
#
-
# @param text [String] The text to render
-
# @param language [Symbol, nil] Optional language for syntax highlighting
-
# @return [String] HTML safe text block
-
1
def render_text_block(text, language = nil)
-
content_tag(:div, class: "relative group") do
-
concat(content_tag(:div, class: "absolute top-2 right-2 flex items-center gap-2") do
-
then: 0
else: 0
if language
-
concat(content_tag(:span, language.to_s.upcase, class: "text-xs font-medium text-slate-400 dark:text-slate-500 uppercase tracking-wider"))
-
end
-
concat(content_tag(:button,
-
'<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>'.html_safe,
-
type: "button",
-
class: "p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 opacity-0 group-hover:opacity-100 transition-opacity",
-
data: { controller: "clipboard", action: "click->clipboard#copy", clipboard_text_value: text },
-
title: "Copy to clipboard"))
-
end)
-
-
concat(content_tag(:pre, class: "bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm font-mono max-h-96 overflow-y-auto whitespace-pre-wrap") do
-
then: 0
else: 0
content_tag(:code, h(text), class: language ? "language-#{language}" : nil)
-
end)
-
end
-
end
-
-
# Highlights JSON string with colors
-
#
-
# @param json_str [String] The JSON string
-
# @return [String] HTML safe highlighted JSON
-
1
def highlight_json(json_str)
-
# Simple JSON syntax highlighting
-
highlighted = h(json_str)
-
.gsub(/("(?:[^"\\]|\\.)*")(\s*:)/) do |match|
-
key = $1
-
colon = $2
-
"<span class=\"text-purple-400\">#{key}</span>#{colon}"
-
end
-
.gsub(/:\s*("(?:[^"\\]|\\.)*")/) do |match|
-
":<span class=\"text-green-400\">#{$1}</span>"
-
end
-
.gsub(/:\s*(true|false)/) do |match|
-
":<span class=\"text-orange-400\">#{$1}</span>"
-
end
-
.gsub(/:\s*(-?\d+(?:\.\d+)?)/) do |match|
-
":<span class=\"text-cyan-400\">#{$1}</span>"
-
end
-
.gsub(/:\s*(null)/) do |match|
-
":<span class=\"text-red-400\">#{$1}</span>"
-
end
-
-
highlighted.html_safe
-
end
-
-
# Detects the language type from field name and content
-
#
-
# @param field_name [Symbol, String] Field name
-
# @param content [String] Content to analyze
-
# @return [Symbol, nil] Detected language
-
1
def detect_language(field_name, content)
-
field_str = field_name.to_s.downcase
-
-
# Check field name hints
-
then: 0
else: 0
return :markdown if field_str.include?("template") || field_str.include?("prompt")
-
then: 0
else: 0
return :ruby if field_str.include?("code") && content.include?("def ")
-
then: 0
else: 0
return :sql if field_str.include?("query") || field_str.include?("sql")
-
then: 0
else: 0
return :html if field_str.include?("html") || field_str.include?("body")
-
-
# Check content hints
-
then: 0
else: 0
return :json if content.strip.start_with?("{", "[")
-
then: 0
else: 0
return :ruby if content.include?("def ") || content.include?("class ")
-
then: 0
else: 0
return :sql if content.upcase.include?("SELECT ") || content.upcase.include?("INSERT ")
-
then: 0
else: 0
return :html if content.include?("<html") || content.include?("<div")
-
-
# Default to text for multi-line content
-
nil
-
end
-
-
# Renders a custom section based on the render type
-
#
-
# @param resource [ActiveRecord::Base] The record
-
# @param render_type [Symbol] Type of custom render
-
# @return [String] HTML safe rendered content
-
1
def render_custom_section(resource, render_type)
-
then: 0
else: 0
if defined?(AdminSuite)
-
renderer = AdminSuite.config.custom_renderers[render_type.to_sym] rescue nil
-
then: 0
else: 0
return renderer.call(resource, self) if renderer
-
end
-
-
case render_type
-
when: 0
when :prompt_template_preview
-
render_prompt_template(resource)
-
when: 0
when :json_preview
-
render_json_preview(resource)
-
when: 0
when :code_preview
-
render_code_preview(resource)
-
when: 0
when :messages_preview
-
render_messages_preview(resource)
-
when: 0
when :tool_args_preview
-
render_tool_args_preview(resource)
-
when: 0
when :turn_messages_preview
-
render_turn_messages_preview(resource)
-
else: 0
else
-
content_tag(:p, "Unknown render type: #{render_type}", class: "text-slate-500 italic")
-
end
-
end
-
-
# Attempts to build an internal developer portal show path for an associated record.
-
# This provides default navigation links for associations even when a resource definition
-
# doesn't explicitly set `link_to:`.
-
#
-
# @param item [ActiveRecord::Base]
-
# @return [String, nil]
-
1
def auto_internal_developer_path_for(item)
-
else: 0
then: 0
return nil unless item.is_a?(ActiveRecord::Base)
-
-
ensure_admin_resources_loaded_for!(item.class)
-
-
resource = Admin::Base::Resource.registered_resources.find { |r| r.model_class == item.class }
-
then: 0
else: 0
else: 0
then: 0
return nil unless resource&.portal_name && resource.respond_to?(:resource_name_plural)
-
-
"/internal/developer/#{resource.portal_name}/#{resource.resource_name_plural}/#{item.to_param}"
-
rescue StandardError
-
nil
-
end
-
-
1
def ensure_admin_resources_loaded_for!(model_class)
-
already_loaded = Admin::Base::Resource.registered_resources.any? { |r| r.model_class == model_class }
-
then: 0
else: 0
return if already_loaded
-
-
Dir[Rails.root.join("app/admin/resources/*.rb").to_s].each do |file|
-
require file
-
end
-
rescue NameError
-
require "admin/base/resource"
-
retry
-
end
-
-
# Renders a prompt template with variable highlighting
-
#
-
# @param resource [ActiveRecord::Base] The record
-
# @return [String] HTML safe template preview
-
1
def render_prompt_template(resource)
-
then: 0
else: 0
template = resource.respond_to?(:prompt_template) ? resource.prompt_template : nil
-
-
then: 0
else: 0
return content_tag(:p, "No template defined", class: "text-slate-500 italic") if template.blank?
-
-
# Highlight template variables
-
highlighted_template = h(template).gsub(/\{\{(\w+)\}\}/) do |match|
-
"<span class=\"text-amber-400 bg-amber-900/30 px-1 rounded\">{{#{$1}}}</span>"
-
end
-
-
content_tag(:div, class: "relative group") do
-
concat(content_tag(:div, class: "absolute top-2 right-2 flex items-center gap-2") do
-
concat(content_tag(:span, "TEMPLATE", class: "text-xs font-medium text-slate-400 dark:text-slate-500 uppercase tracking-wider"))
-
concat(content_tag(:button,
-
'<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>'.html_safe,
-
type: "button",
-
class: "p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 opacity-0 group-hover:opacity-100 transition-opacity",
-
data: { controller: "clipboard", action: "click->clipboard#copy", clipboard_text_value: template },
-
title: "Copy to clipboard"))
-
end)
-
-
concat(content_tag(:pre, class: "bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm font-mono max-h-[600px] overflow-y-auto whitespace-pre-wrap leading-relaxed") do
-
highlighted_template.html_safe
-
end)
-
-
# Show template variables
-
variables = template.scan(/\{\{(\w+)\}\}/).flatten.uniq
-
then: 0
else: 0
if variables.any?
-
concat(content_tag(:div, class: "mt-3 pt-3 border-t border-slate-700") do
-
concat(content_tag(:span, "Variables: ", class: "text-sm text-slate-400"))
-
concat(content_tag(:div, class: "inline-flex flex-wrap gap-1 mt-1") do
-
variables.each do |var|
-
concat(content_tag(:code, "{{#{var}}}", class: "text-xs px-2 py-0.5 bg-amber-900/30 text-amber-400 rounded"))
-
end
-
end)
-
end)
-
end
-
end
-
end
-
-
# Renders a JSON preview (for arbitrary JSON fields)
-
#
-
# @param resource [ActiveRecord::Base] The record
-
# @return [String] HTML safe JSON preview
-
1
def render_json_preview(resource)
-
then: 0
else: 0
data = resource.respond_to?(:data) ? resource.data : resource.attributes
-
render_json_block(data)
-
end
-
-
# Renders a code preview
-
#
-
# @param resource [ActiveRecord::Base] The record
-
# @return [String] HTML safe code preview
-
1
def render_code_preview(resource)
-
then: 0
else: 0
code = resource.respond_to?(:code) ? resource.code : resource.to_s
-
render_text_block(code, :ruby)
-
end
-
-
# Renders messages preview (for chat threads)
-
#
-
# @param resource [ActiveRecord::Base] The record
-
# @return [String] HTML safe messages preview
-
1
def render_messages_preview(resource)
-
then: 0
else: 0
messages = resource.respond_to?(:messages) ? resource.messages.chronological.limit(50) : []
-
-
then: 0
else: 0
return content_tag(:p, "No messages", class: "text-slate-500 italic") if messages.blank?
-
-
content_tag(:div, class: "space-y-4 max-h-[600px] overflow-y-auto -mx-6 -mb-6 p-6 pt-0") do
-
messages.each_with_index do |msg, idx|
-
# Handle both ActiveRecord objects and Hash messages
-
then: 0
if msg.respond_to?(:role)
-
role = msg.role
-
content = msg.content
-
created_at = msg.created_at
-
else: 0
else
-
role = msg["role"] || msg[:role] || "unknown"
-
content = msg["content"] || msg[:content] || ""
-
created_at = nil
-
end
-
-
when: 0
role_class = case role.to_s
-
when: 0
when "user" then "bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800"
-
when: 0
when "assistant" then "bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800"
-
when: 0
when "tool" then "bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800"
-
else: 0
when "system" then "bg-slate-50 dark:bg-slate-700/50 border-slate-200 dark:border-slate-600"
-
else "bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700"
-
end
-
-
role_icon = case role.to_s
-
when: 0
when "user"
-
'<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>'.html_safe
-
when: 0
when "assistant"
-
'<svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/></svg>'.html_safe
-
when: 0
when "tool"
-
'<svg class="w-4 h-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/></svg>'.html_safe
-
else: 0
else
-
'<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>'.html_safe
-
end
-
-
concat(content_tag(:div, class: "rounded-lg border p-4 #{role_class}") do
-
concat(content_tag(:div, class: "flex items-center justify-between mb-3") do
-
concat(content_tag(:div, class: "flex items-center gap-2") do
-
concat(role_icon)
-
concat(content_tag(:span, role.to_s.capitalize, class: "text-sm font-medium text-slate-700 dark:text-slate-200"))
-
end)
-
concat(content_tag(:div, class: "flex items-center gap-2 text-xs text-slate-400") do
-
then: 0
else: 0
if created_at
-
concat(content_tag(:span, created_at.strftime("%H:%M:%S")))
-
end
-
concat(content_tag(:span, "##{idx + 1}"))
-
end)
-
end)
-
-
# Render content - detect if it's JSON or code
-
content_str = content.to_s
-
then: 0
if role.to_s == "tool" && content_str.start_with?("{", "[")
-
begin
-
parsed = JSON.parse(content_str)
-
concat(render_json_block(parsed))
-
rescue JSON::ParserError
-
concat(content_tag(:div, simple_format(h(content_str)), class: "prose dark:prose-invert prose-sm max-w-none"))
-
end
-
else: 0
else
-
concat(content_tag(:div, simple_format(h(content_str)), class: "prose dark:prose-invert prose-sm max-w-none"))
-
end
-
end)
-
end
-
end
-
end
-
-
# Renders tool arguments preview
-
#
-
# @param resource [ActiveRecord::Base] The record
-
# @return [String] HTML safe tool args preview
-
1
def render_tool_args_preview(resource)
-
# ToolExecution uses 'args' not 'arguments'
-
then: 0
else: 0
then: 0
else: 0
args = resource.respond_to?(:args) ? resource.args : (resource.respond_to?(:arguments) ? resource.arguments : {})
-
then: 0
else: 0
result = resource.respond_to?(:result) ? resource.result : nil
-
then: 0
else: 0
error = resource.respond_to?(:error) ? resource.error : nil
-
-
content_tag(:div, class: "space-y-6") do
-
# Arguments section
-
concat(content_tag(:div) do
-
concat(content_tag(:h4, "Arguments", class: "text-sm font-medium text-slate-500 dark:text-slate-400 mb-2"))
-
then: 0
if args.present? && args != {}
-
concat(render_json_block(args))
-
else: 0
else
-
concat(content_tag(:p, "No arguments", class: "text-slate-400 italic text-sm"))
-
end
-
end)
-
-
# Result section
-
then: 0
else: 0
if result.present? && result != {}
-
concat(content_tag(:div, class: "pt-4 border-t border-slate-200 dark:border-slate-700") do
-
concat(content_tag(:h4, "Result", class: "text-sm font-medium text-slate-500 dark:text-slate-400 mb-2"))
-
concat(render_json_block(result))
-
end)
-
end
-
-
# Error section
-
then: 0
else: 0
if error.present?
-
concat(content_tag(:div, class: "pt-4 border-t border-slate-200 dark:border-slate-700") do
-
concat(content_tag(:h4, "Error", class: "text-sm font-medium text-red-500 dark:text-red-400 mb-2"))
-
concat(content_tag(:div, class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4") do
-
content_tag(:pre, h(error.to_s), class: "text-sm text-red-700 dark:text-red-300 whitespace-pre-wrap font-mono")
-
end)
-
end)
-
end
-
end
-
end
-
-
# Renders a show page section based on its configuration
-
#
-
# @param resource [ActiveRecord::Base] The record
-
# @param section [ShowSectionDefinition] Section definition
-
# @param position [Symbol] :sidebar or :main
-
# @return [String] HTML safe section
-
1
def render_show_section(resource, section, position = :main)
-
# Check if this is an association section (needs tighter header-content spacing)
-
is_association = section.association.present? && !resource.public_send(section.association).is_a?(ActiveRecord::Base) rescue false
-
-
content_tag(:div, class: "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden") do
-
# Header
-
then: 0
else: 0
header_padding = position == :sidebar ? "px-4 py-2.5" : "px-6 py-3"
-
then: 0
else: 0
header_text_size = position == :sidebar ? "text-sm" : ""
-
then: 0
else: 0
header_border = is_association ? "" : "border-b border-slate-200 dark:border-slate-700"
-
-
concat(content_tag(:div, class: "#{header_padding} #{header_border} bg-slate-50 dark:bg-slate-900/50 flex items-center justify-between") do
-
concat(content_tag(:h3, section.title, class: "font-medium text-slate-900 dark:text-white #{header_text_size}"))
-
-
# Show count for associations
-
then: 0
else: 0
if section.association.present?
-
assoc = resource.public_send(section.association) rescue nil
-
then: 0
else: 0
if assoc && !assoc.is_a?(ActiveRecord::Base)
-
count = assoc.count rescue 0
-
then: 0
else: 0
color_class = count > 0 ? "bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400" : "bg-slate-200 dark:bg-slate-600 text-slate-600 dark:text-slate-300"
-
concat(content_tag(:span, number_with_delimiter(count), class: "text-xs font-semibold px-2 py-0.5 rounded-full #{color_class}"))
-
end
-
end
-
end)
-
-
# Content
-
then: 0
else: 0
content_padding = position == :sidebar ? "p-4" : "p-6"
-
then: 0
else: 0
if is_association && position == :main
-
then: 0
else: 0
content_padding = section.paginate ? "pt-0 px-6 pb-0" : "pt-0 px-6 pb-6"
-
end
-
then: 0
else: 0
content_padding = "pt-0 p-4" if is_association && position == :sidebar
-
-
concat(content_tag(:div, class: content_padding) do
-
if section.render.present?
-
then: 0
# Custom renderer
-
else: 0
render_custom_section(resource, section.render)
-
elsif section.association.present?
-
then: 0
# Association display
-
else: 0
render_association_section(resource, section)
-
elsif section.fields.any?
-
then: 0
# Field display
-
then: 0
if position == :sidebar
-
render_sidebar_fields(resource, section.fields)
-
else: 0
else
-
render_main_fields(resource, section.fields)
-
end
-
else: 0
else
-
content_tag(:p, "No content", class: "text-slate-400 italic text-sm")
-
end
-
end)
-
end
-
end
-
-
# Renders fields for sidebar (compact layout)
-
#
-
# @param resource [ActiveRecord::Base] The record
-
# @param fields [Array<Symbol>] Field names
-
# @return [String] HTML safe fields
-
1
def render_sidebar_fields(resource, fields)
-
content_tag(:div, class: "space-y-3") do
-
fields.each do |field_name|
-
value = resource.public_send(field_name) rescue nil
-
-
# Special handling for attachments in sidebar - show image prominently
-
then: 0
if value.is_a?(ActiveStorage::Attached::One) || value.is_a?(ActiveStorage::Attached::Many)
-
concat(render_sidebar_attachment(value))
-
else: 0
else
-
concat(content_tag(:div, class: "flex justify-between items-start gap-2") do
-
concat(content_tag(:span, field_name.to_s.humanize, class: "text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider flex-shrink-0"))
-
concat(content_tag(:span, class: "text-sm text-slate-900 dark:text-white text-right") do
-
format_show_value(resource, field_name)
-
end)
-
end)
-
end
-
end
-
end
-
end
-
-
# Renders an attachment for sidebar display (compact, image-focused)
-
#
-
# @param attachment [ActiveStorage::Attached] The attachment
-
# @return [String] HTML safe attachment preview
-
1
def render_sidebar_attachment(attachment)
-
else: 0
then: 0
return content_tag(:div, class: "text-center py-4") do
-
content_tag(:span, "No image", class: "text-slate-400 text-sm")
-
end unless attachment.respond_to?(:attached?) && attachment.attached?
-
-
then: 0
else: 0
blob = attachment.is_a?(ActiveStorage::Attached::Many) ? attachment.first.blob : attachment.blob
-
-
then: 0
if blob.image?
-
content_tag(:div, class: "space-y-2") do
-
# Image preview - full width in sidebar
-
concat(content_tag(:div, class: "rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700") do
-
image_tag(attachment.variant(resize_to_limit: [ 400, 300 ]),
-
class: "w-full h-auto object-cover",
-
alt: blob.filename.to_s)
-
end)
-
# Compact metadata
-
concat(content_tag(:div, class: "flex items-center justify-between text-xs text-slate-500 dark:text-slate-400") do
-
concat(content_tag(:span, number_to_human_size(blob.byte_size)))
-
concat(link_to("View full", rails_blob_path(blob, disposition: :inline),
-
target: "_blank",
-
class: "text-indigo-600 dark:text-indigo-400 hover:underline"))
-
end)
-
end
-
else
-
else: 0
# Non-image file in sidebar
-
content_tag(:div, class: "flex items-center gap-2 p-2 bg-slate-50 dark:bg-slate-800 rounded-lg") do
-
concat(content_tag(:div, class: "flex-shrink-0 w-8 h-8 bg-slate-200 dark:bg-slate-700 rounded flex items-center justify-center") do
-
'<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>'.html_safe
-
end)
-
concat(content_tag(:div, class: "flex-1 min-w-0") do
-
concat(content_tag(:p, blob.filename.to_s.truncate(20), class: "text-xs font-medium text-slate-700 dark:text-slate-300 truncate"))
-
concat(content_tag(:p, number_to_human_size(blob.byte_size), class: "text-xs text-slate-500"))
-
end)
-
end
-
end
-
end
-
-
# Renders fields for main content (full layout)
-
#
-
# @param resource [ActiveRecord::Base] The record
-
# @param fields [Array<Symbol>] Field names
-
# @return [String] HTML safe fields
-
1
def render_main_fields(resource, fields)
-
content_tag(:dl, class: "space-y-6") do
-
fields.each do |field_name|
-
concat(content_tag(:div) do
-
concat(content_tag(:dt, field_name.to_s.humanize, class: "text-sm font-medium text-slate-500 dark:text-slate-400 mb-2"))
-
concat(content_tag(:dd, class: "text-sm text-slate-900 dark:text-white") do
-
format_show_value(resource, field_name)
-
end)
-
end)
-
end
-
end
-
end
-
-
# Renders an association section
-
#
-
# @param resource [ActiveRecord::Base] The record
-
# @param section [ShowSectionDefinition] Section definition
-
# @return [String] HTML safe association display
-
1
def render_association_section(resource, section)
-
associated = resource.public_send(section.association) rescue nil
-
-
then: 0
else: 0
return content_tag(:p, "None found", class: "text-slate-400 italic text-sm") if associated.nil?
-
-
# Check if this is a belongs_to (single record) or has_many (collection)
-
is_single = !associated.respond_to?(:to_a) || associated.is_a?(ActiveRecord::Base)
-
-
else: 0
if is_single
-
then: 0
# Single record (belongs_to)
-
return render_association_card_single(associated, section)
-
end
-
-
items = associated
-
pagy = nil
-
-
then: 0
if section.paginate
-
per_page = (section.per_page || section.limit || 20).to_i
-
then: 0
else: 0
per_page = 1 if per_page < 1
-
page_param = association_page_param(section)
-
page = params[page_param].presence || 1
-
then: 0
total_count = if associated.respond_to?(:count)
-
associated.count
-
else: 0
else
-
associated.to_a.size
-
end
-
-
pagy = Pagy.new(count: total_count, page: page, limit: per_page, page_param: page_param)
-
then: 0
if associated.respond_to?(:offset)
-
items = associated.offset(pagy.offset).limit(per_page)
-
else: 0
else
-
items = Array.wrap(associated)[pagy.offset, per_page] || []
-
else: 0
end
-
then: 0
else: 0
elsif section.limit
-
then: 0
if associated.respond_to?(:limit)
-
items = associated.limit(section.limit)
-
else: 0
else
-
items = Array.wrap(associated).first(section.limit)
-
end
-
end
-
-
items = Array.wrap(items)
-
-
then: 0
else: 0
return content_tag(:p, "None found", class: "text-slate-400 italic text-sm") if items.empty?
-
-
content_tag(:div) do
-
case section.display
-
when: 0
when :table
-
concat(render_association_table(items, section))
-
when: 0
when :cards
-
concat(render_association_cards(items, section))
-
else: 0
else
-
concat(render_association_list(items, section))
-
end
-
-
then: 0
else: 0
concat(render_association_pagination(pagy)) if pagy
-
end
-
end
-
-
1
def association_page_param(section)
-
"#{section.association}_page"
-
end
-
-
1
def render_association_pagination(pagy)
-
content_tag(:div, class: "-mx-6 border-t border-slate-200 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-900/30 px-6 py-3") do
-
content_tag(:nav, class: "flex items-center justify-between", "aria-label" => "Pagination") do
-
concat(pagy_prev_link(pagy))
-
concat(pagy_page_links(pagy))
-
concat(pagy_next_link(pagy))
-
end
-
end
-
end
-
-
1
def pagy_prev_link(pagy)
-
then: 0
if pagy.prev
-
link_to("Prev", pagy_url_for(pagy, pagy.prev),
-
class: "px-3 py-1.5 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors")
-
else: 0
else
-
content_tag(:span, "Prev",
-
class: "px-3 py-1.5 text-sm font-medium text-slate-400 dark:text-slate-500 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg cursor-not-allowed")
-
end
-
end
-
-
1
def pagy_next_link(pagy)
-
then: 0
if pagy.next
-
link_to("Next", pagy_url_for(pagy, pagy.next),
-
class: "px-3 py-1.5 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors")
-
else: 0
else
-
content_tag(:span, "Next",
-
class: "px-3 py-1.5 text-sm font-medium text-slate-400 dark:text-slate-500 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg cursor-not-allowed")
-
end
-
end
-
-
1
def pagy_page_links(pagy)
-
content_tag(:div, class: "flex items-center gap-1") do
-
pagy.series.each do |item|
-
concat(render_pagy_series_item(pagy, item))
-
end
-
end
-
end
-
-
1
def render_pagy_series_item(pagy, item)
-
case item
-
when: 0
when Integer
-
link_to(item, pagy_url_for(pagy, item),
-
class: "px-2.5 py-1 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors")
-
when: 0
when String
-
content_tag(:span, item,
-
class: "px-2.5 py-1 text-sm font-semibold text-white bg-indigo-600 border border-indigo-600 rounded")
-
when: 0
when :gap
-
content_tag(:span, "…", class: "px-2 text-sm text-slate-400 dark:text-slate-500")
-
else: 0
else
-
""
-
end
-
end
-
-
# Renders a single associated record (belongs_to)
-
#
-
# @param item [ActiveRecord::Base] The associated record
-
# @param section [ShowSectionDefinition] Section definition
-
# @return [String] HTML safe card
-
1
def render_association_card_single(item, section)
-
link_path = build_association_link(item, section)
-
-
card_content = capture do
-
# Title row
-
concat(content_tag(:div, class: "flex items-center justify-between gap-3") do
-
concat(content_tag(:div, class: "min-w-0 flex-1") do
-
title = item_display_title(item)
-
then: 0
else: 0
title_class = link_path ? "font-medium text-slate-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400" : "font-medium text-slate-900 dark:text-white"
-
concat(content_tag(:div, title, class: title_class))
-
-
# Subtitle with extra info
-
subtitle = []
-
then: 0
else: 0
subtitle << item.status.to_s.humanize if item.respond_to?(:status) && item.status.present?
-
then: 0
else: 0
subtitle << item.email_address if item.respond_to?(:email_address) && item.email_address.present?
-
then: 0
else: 0
subtitle << item.tool_key if item.respond_to?(:tool_key) && item.tool_key.present?
-
-
then: 0
else: 0
if subtitle.any?
-
concat(content_tag(:div, subtitle.first, class: "text-sm text-slate-500 dark:text-slate-400 mt-0.5"))
-
end
-
end)
-
-
then: 0
else: 0
if link_path
-
concat('<svg class="w-5 h-5 text-slate-300 dark:text-slate-600 group-hover:text-indigo-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>'.html_safe)
-
end
-
end)
-
end
-
-
then: 0
if link_path
-
link_to(card_content, link_path, class: "flex items-center -m-4 p-4 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/10 transition-colors group")
-
else: 0
else
-
content_tag(:div, card_content, class: "flex items-center")
-
end
-
end
-
-
# Renders association as a list
-
#
-
# @param items [Array] Associated records
-
# @param section [ShowSectionDefinition] Section definition
-
# @return [String] HTML safe list
-
1
def render_association_list(items, section)
-
content_tag(:div, class: "divide-y divide-slate-200 dark:divide-slate-700 -mx-6 -mt-2 -mb-6") do
-
items.each do |item|
-
link_path = build_association_link(item, section)
-
-
then: 0
wrapper = if link_path
-
->(content) { link_to(link_path, class: "block px-6 py-4 hover:bg-indigo-50/50 dark:hover:bg-indigo-900/10 transition-colors group") { content } }
-
else: 0
else
-
->(content) { content_tag(:div, content, class: "px-6 py-4") }
-
end
-
-
concat(wrapper.call(capture do
-
# Main row
-
concat(content_tag(:div, class: "flex items-start justify-between gap-4") do
-
# Left: Title and subtitle
-
concat(content_tag(:div, class: "min-w-0 flex-1") do
-
# Title with link indicator
-
concat(content_tag(:div, class: "flex items-center gap-2") do
-
title = item_display_title(item)
-
then: 0
else: 0
title_class = link_path ? "text-slate-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400" : "text-slate-900 dark:text-white"
-
concat(content_tag(:span, title.truncate(60), class: "font-medium #{title_class} truncate"))
-
-
then: 0
else: 0
if item.respond_to?(:status) && item.status.present?
-
concat(render_status_badge(item.status, size: :sm))
-
end
-
end)
-
-
# Subtitle with extra info
-
subtitle_parts = []
-
then: 0
else: 0
subtitle_parts << item.description.to_s.truncate(80) if item.respond_to?(:description) && item.description.present?
-
then: 0
else: 0
subtitle_parts << item.tool_key if item.respond_to?(:tool_key) && item.tool_key.present?
-
then: 0
else: 0
subtitle_parts << item.role.to_s.humanize if item.respond_to?(:role) && item.role.present?
-
then: 0
else: 0
subtitle_parts << item.provider if item.respond_to?(:provider) && item.provider.present?
-
-
then: 0
else: 0
if subtitle_parts.any?
-
concat(content_tag(:p, subtitle_parts.first, class: "text-sm text-slate-500 dark:text-slate-400 mt-0.5 truncate"))
-
end
-
end)
-
-
# Right: Meta info
-
concat(content_tag(:div, class: "flex items-center gap-3 flex-shrink-0 text-xs text-slate-400") do
-
# Type-specific badges
-
then: 0
else: 0
if item.respond_to?(:active) || item.respond_to?(:active?)
-
then: 0
else: 0
is_active = item.respond_to?(:active?) ? item.active? : item.active
-
then: 0
else: 0
concat(content_tag(:span, is_active ? "Active" : "Inactive",
-
then: 0
else: 0
class: is_active ? "px-1.5 py-0.5 rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400" : "px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-700 text-slate-500"))
-
end
-
-
# Duration if available
-
then: 0
if item.respond_to?(:duration_seconds) && item.duration_seconds.present?
-
else: 0
concat(content_tag(:span, "#{item.duration_seconds.round(1)}s", class: "font-mono"))
-
then: 0
else: 0
elsif item.respond_to?(:duration_ms) && item.duration_ms.present?
-
concat(content_tag(:span, "#{item.duration_ms}ms", class: "font-mono"))
-
end
-
-
# Timestamp
-
then: 0
else: 0
if item.respond_to?(:created_at) && item.created_at
-
concat(content_tag(:span, time_ago_in_words(item.created_at) + " ago"))
-
end
-
-
# Arrow indicator for links
-
then: 0
else: 0
if link_path
-
concat('<svg class="w-4 h-4 text-slate-300 dark:text-slate-600 group-hover:text-indigo-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>'.html_safe)
-
end
-
end)
-
end)
-
end))
-
end
-
end
-
end
-
-
# Renders association as a table
-
#
-
# @param items [Array] Associated records
-
# @param section [ShowSectionDefinition] Section definition
-
# @return [String] HTML safe table
-
1
def render_association_table(items, section)
-
resource_columns = association_resource_columns(section)
-
-
# Smart column detection if not specified
-
then: 0
columns = if section.columns.present?
-
else: 0
section.columns.map { |col| resolve_association_column(col, resource_columns) }
-
then: 0
elsif resource_columns.any?
-
resource_columns
-
else: 0
else
-
detect_table_columns(items.first)
-
end
-
-
content_tag(:div, class: "overflow-x-auto -mx-6 -mt-1") do
-
content_tag(:table, class: "min-w-full divide-y divide-slate-200 dark:divide-slate-700") do
-
# Header
-
concat(content_tag(:thead, class: "bg-slate-50/50 dark:bg-slate-900/30") do
-
content_tag(:tr) do
-
columns.each do |col|
-
then: 0
else: 0
header = association_column_definition?(col) ? col.header : col.to_s.gsub(/_id$/, "").humanize
-
concat(content_tag(:th, header, class: "px-4 py-2.5 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider first:pl-6"))
-
end
-
then: 0
else: 0
if section.link_to.present?
-
concat(content_tag(:th, "", class: "px-4 py-2.5 w-16")) # Actions column
-
end
-
end
-
end)
-
-
# Body
-
concat(content_tag(:tbody, class: "divide-y divide-slate-200 dark:divide-slate-700") do
-
items.each do |item|
-
link_path = build_association_link(item, section)
-
then: 0
else: 0
row_class = link_path ? "hover:bg-indigo-50/50 dark:hover:bg-indigo-900/10 cursor-pointer group" : "hover:bg-slate-50 dark:hover:bg-slate-900/30"
-
-
then: 0
else: 0
concat(content_tag(:tr, class: row_class, data: link_path ? { turbo_frame: "_top" } : {}) do
-
columns.each_with_index do |col, idx|
-
then: 0
else: 0
td_class = idx == 0 ? "px-4 py-3 text-sm first:pl-6" : "px-4 py-3 text-sm"
-
then: 0
formatted = if association_column_definition?(col)
-
render_association_column_value(item, col, section, link_path && idx == 0)
-
else: 0
else
-
value = item.public_send(col) rescue nil
-
format_table_cell_enhanced(item, col, value, link_path && idx == 0)
-
end
-
concat(content_tag(:td, formatted, class: td_class))
-
end
-
-
# Actions
-
then: 0
else: 0
if section.link_to.present? && link_path
-
concat(content_tag(:td, class: "px-4 py-3 text-right pr-6") do
-
link_to(link_path, class: "inline-flex items-center text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 text-sm font-medium") do
-
"View".html_safe
-
end
-
end)
-
end
-
end)
-
end
-
end)
-
end
-
end
-
end
-
-
1
def association_resource_columns(section)
-
resource = section.resource
-
else: 0
then: 0
return [] unless resource.respond_to?(:index_config)
-
-
then: 0
else: 0
resource.index_config&.columns_list || []
-
end
-
-
1
def association_column_definition?(column)
-
column.is_a?(Admin::Base::Resource::ColumnDefinition)
-
end
-
-
1
def resolve_association_column(column, resource_columns)
-
else: 0
then: 0
return column unless column.is_a?(Symbol) || column.is_a?(String)
-
-
resource_columns.find { |resource_column| resource_column.name.to_sym == column.to_sym } || column
-
end
-
-
1
def render_association_column_value(item, column, section, is_primary)
-
then: 0
else: 0
if column.type == :toggle
-
field = (column.toggle_field || column.name).to_sym
-
toggle_url = toggle_url_for_resource(section.resource, item, field)
-
return render partial: "internal/developer/shared/toggle_cell",
-
locals: { record: item, field: field, toggle_url: toggle_url }
-
end
-
then: 0
else: 0
if column.type == :label
-
then: 0
else: 0
value = column.content.is_a?(Proc) ? column.content.call(item) : (item.public_send(column.name) rescue nil)
-
return render_label_badge(value, color: column.label_color, size: column.label_size, record: item)
-
end
-
-
then: 0
value = if column.content.is_a?(Proc)
-
column.content.call(item)
-
else: 0
else
-
item.public_send(column.name) rescue nil
-
end
-
-
format_table_cell_enhanced(item, column.name, value, is_primary)
-
end
-
-
1
def toggle_url_for_resource(resource, record, field)
-
then: 0
else: 0
else: 0
then: 0
return nil unless resource&.portal_name && resource.respond_to?(:resource_name_plural)
-
-
internal_developer_resource_toggle_path(
-
portal: resource.portal_name,
-
resource_name: resource.resource_name_plural,
-
id: record.id,
-
field: field
-
)
-
rescue StandardError
-
nil
-
end
-
-
# Detects appropriate columns for a table based on record attributes
-
#
-
# @param item [ActiveRecord::Base] Sample record
-
# @return [Array<Symbol>] Column names
-
1
def detect_table_columns(item)
-
else: 0
then: 0
return [ :id, :name, :created_at ] unless item
-
-
# Priority columns to show
-
priority = [ :name, :title, :status, :role, :tool_key, :provider, :model ]
-
# Columns to skip
-
skip = [ :id, :created_at, :updated_at, :password_digest, :encrypted_password ]
-
-
attrs = item.attributes.keys.map(&:to_sym)
-
-
# Start with priority columns that exist
-
selected = priority.select { |c| attrs.include?(c) }
-
-
# Add other relevant columns
-
attrs.each do |col|
-
then: 0
else: 0
next if skip.include?(col)
-
then: 0
else: 0
next if selected.include?(col)
-
then: 0
else: 0
next if col.to_s.end_with?("_id") # Skip foreign keys, show relations instead
-
then: 0
else: 0
next if col.to_s.include?("token") || col.to_s.include?("secret")
-
then: 0
else: 0
next if selected.size >= 5
-
-
selected << col
-
end
-
-
# Always include created_at at the end if space
-
then: 0
else: 0
selected << :created_at if selected.size < 5 && attrs.include?(:created_at)
-
-
selected.take(5)
-
end
-
-
# Enhanced table cell formatting
-
#
-
# @param item [ActiveRecord::Base] The record
-
# @param column [Symbol] Column name
-
# @param value [Object] The value
-
# @param is_primary [Boolean] Whether this is the primary/title column
-
# @return [String] Formatted value
-
1
def format_table_cell_enhanced(item, column, value, is_primary = false)
-
case value
-
when: 0
when nil
-
content_tag(:span, "—", class: "text-slate-400")
-
when: 0
when true
-
content_tag(:span, class: "inline-flex items-center gap-1") do
-
'<svg class="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>'.html_safe
-
end
-
when: 0
when false
-
content_tag(:span, class: "inline-flex items-center gap-1") do
-
'<svg class="w-4 h-4 text-slate-300" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>'.html_safe
-
end
-
when: 0
when Time, DateTime
-
content_tag(:span, value.strftime("%b %d, %H:%M"), class: "text-slate-600 dark:text-slate-400")
-
when: 0
when Date
-
content_tag(:span, value.strftime("%b %d, %Y"), class: "text-slate-600 dark:text-slate-400")
-
when: 0
when Integer, Float, BigDecimal
-
then: 0
if column.to_s.include?("duration") || column.to_s.include?("latency")
-
else: 0
content_tag(:span, "#{value}ms", class: "font-mono text-slate-600 dark:text-slate-400")
-
then: 0
elsif column.to_s.include?("cost") || column.to_s.include?("cents")
-
else: 0
content_tag(:span, "$#{(value / 100.0).round(4)}", class: "font-mono text-slate-600 dark:text-slate-400")
-
then: 0
elsif column.to_s.include?("token")
-
content_tag(:span, number_with_delimiter(value), class: "font-mono text-slate-600 dark:text-slate-400")
-
else: 0
else
-
content_tag(:span, number_with_delimiter(value), class: "text-slate-900 dark:text-white")
-
end
-
when: 0
when ActiveRecord::Base
-
then: 0
else: 0
display = value.respond_to?(:name) ? value.name : value.class.name.demodulize
-
content_tag(:span, display.to_s.truncate(25), class: "text-slate-600 dark:text-slate-400")
-
else: 0
else
-
str = value.to_s
-
then: 0
if column == :status || column.to_s.end_with?("_status")
-
else: 0
render_status_badge(value, size: :sm)
-
then: 0
elsif is_primary
-
content_tag(:span, str.truncate(50), class: "font-medium text-slate-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400")
-
else: 0
else
-
content_tag(:span, str.truncate(40), class: "text-slate-900 dark:text-white")
-
end
-
end
-
end
-
-
# Renders association as cards
-
#
-
# @param items [Array] Associated records
-
# @param section [ShowSectionDefinition] Section definition
-
# @return [String] HTML safe cards
-
1
def render_association_cards(items, section)
-
content_tag(:div, class: "grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1") do
-
items.each do |item|
-
link_path = build_association_link(item, section)
-
-
card_class = "border border-slate-200 dark:border-slate-700 rounded-lg p-4 transition-all"
-
then: 0
else: 0
card_class += link_path ? " hover:border-indigo-300 dark:hover:border-indigo-700 hover:shadow-md group cursor-pointer" : " hover:bg-slate-50 dark:hover:bg-slate-900/30"
-
-
card_content = capture do
-
# Header with title and status
-
concat(content_tag(:div, class: "flex items-start justify-between gap-2 mb-2") do
-
title = item_display_title(item)
-
then: 0
else: 0
title_class = link_path ? "font-medium text-slate-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400" : "font-medium text-slate-900 dark:text-white"
-
concat(content_tag(:span, title.truncate(35), class: title_class))
-
-
then: 0
else: 0
if item.respond_to?(:status) && item.status.present?
-
concat(render_status_badge(item.status, size: :sm))
-
end
-
end)
-
-
# Description or key info
-
info_parts = []
-
then: 0
else: 0
info_parts << item.description.to_s.truncate(80) if item.respond_to?(:description) && item.description.present?
-
then: 0
else: 0
info_parts << "Tool: #{item.tool_key}" if item.respond_to?(:tool_key) && item.tool_key.present?
-
then: 0
else: 0
info_parts << "Role: #{item.role.to_s.humanize}" if item.respond_to?(:role) && item.role.present?
-
then: 0
else: 0
info_parts << "Provider: #{item.provider}" if item.respond_to?(:provider) && item.provider.present?
-
-
then: 0
else: 0
if info_parts.any?
-
concat(content_tag(:p, info_parts.first, class: "text-sm text-slate-500 dark:text-slate-400 mb-3 line-clamp-2"))
-
end
-
-
# Footer with meta info
-
concat(content_tag(:div, class: "flex items-center justify-between text-xs text-slate-400 pt-2 border-t border-slate-100 dark:border-slate-700/50") do
-
# Left: timestamp
-
then: 0
else: 0
if item.respond_to?(:created_at) && item.created_at
-
concat(content_tag(:span, time_ago_in_words(item.created_at) + " ago"))
-
end
-
-
# Right: additional info or link arrow
-
then: 0
if link_path
-
else: 0
concat('<svg class="w-4 h-4 text-slate-300 dark:text-slate-600 group-hover:text-indigo-500 group-hover:translate-x-0.5 transition-all" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>'.html_safe)
-
then: 0
else: 0
elsif item.respond_to?(:active) || item.respond_to?(:active?)
-
then: 0
else: 0
is_active = item.respond_to?(:active?) ? item.active? : item.active
-
then: 0
else: 0
concat(content_tag(:span, is_active ? "Active" : "Inactive",
-
then: 0
else: 0
class: is_active ? "text-green-600 dark:text-green-400" : "text-slate-400"))
-
end
-
end)
-
end
-
-
then: 0
if link_path
-
concat(link_to(card_content, link_path, class: card_class))
-
else: 0
else
-
concat(content_tag(:div, card_content, class: card_class))
-
end
-
end
-
end
-
end
-
-
# Formats a value for table cell display
-
#
-
# @param value [Object] The value
-
# @return [String] Formatted value
-
1
def format_table_cell(value)
-
case value
-
when: 0
when nil
-
"—"
-
when: 0
when true, false
-
then: 0
else: 0
value ? "Yes" : "No"
-
when: 0
when Time, DateTime
-
value.strftime("%b %d, %H:%M")
-
when: 0
when Date
-
value.strftime("%b %d, %Y")
-
when: 0
when ActiveRecord::Base
-
item_display_title(value)
-
else: 0
else
-
value.to_s.truncate(50)
-
end
-
end
-
-
# Returns a display title for an item
-
#
-
# @param item [ActiveRecord::Base] The record
-
# @return [String] Display title
-
1
def item_display_title(item)
-
then: 0
else: 0
return item.name if item.respond_to?(:name) && item.name.present?
-
then: 0
else: 0
return item.title if item.respond_to?(:title) && item.title.present?
-
then: 0
else: 0
return item.display_title if item.respond_to?(:display_title) && item.display_title.present?
-
then: 0
else: 0
return item.content.to_s.truncate(50) if item.respond_to?(:content)
-
then: 0
else: 0
return item.tool_key if item.respond_to?(:tool_key)
-
"##{item.id}"
-
end
-
-
# Builds a link path for an associated item
-
#
-
# @param item [ActiveRecord::Base] The record
-
# @param section [ShowSectionDefinition] Section definition
-
# @return [String, nil] Link path or nil
-
1
def build_association_link(item, section)
-
then: 0
else: 0
if section.link_to.present?
-
begin
-
return send(section.link_to, item)
-
rescue NoMethodError
-
# fall through to auto-link
-
end
-
end
-
-
auto_internal_developer_path_for(item)
-
end
-
-
# Renders a status badge
-
#
-
# @param status [String, Symbol] The status
-
# @param size [Symbol] Badge size (:sm, :md)
-
# @return [String] HTML safe badge
-
1
def render_status_badge(status, size: :md)
-
then: 0
else: 0
return content_tag(:span, "—", class: "text-slate-400") if status.blank?
-
-
status_str = status.to_s.downcase
-
-
colors = case status_str
-
when: 0
when "active", "open", "success", "approved", "completed", "enabled"
-
"bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"
-
when: 0
when "pending", "proposed", "queued", "waiting"
-
"bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400"
-
when: 0
when "running", "processing", "in_progress"
-
"bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400"
-
when: 0
when "error", "failed", "rejected", "cancelled"
-
"bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400"
-
when: 0
when "inactive", "closed", "disabled", "archived"
-
"bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400"
-
else: 0
else
-
"bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400"
-
end
-
-
then: 0
else: 0
padding = size == :sm ? "px-1.5 py-0.5 text-xs" : "px-2 py-1 text-xs"
-
-
content_tag(:span, status_str.titleize, class: "inline-flex items-center #{padding} rounded-full font-medium #{colors}")
-
end
-
-
1
def render_label_badge(value, color: nil, size: :md, record: nil)
-
then: 0
else: 0
return content_tag(:span, "—", class: "text-slate-400") if value.blank?
-
-
label_color = resolve_label_option(color, record).presence || :slate
-
label_size = resolve_label_option(size, record).presence || :md
-
colors = label_badge_colors(label_color)
-
then: 0
else: 0
padding = label_size.to_s == "sm" ? "px-1.5 py-0.5 text-xs" : "px-2 py-1 text-xs"
-
-
content_tag(:span, value.to_s, class: "inline-flex items-center #{padding} rounded-md font-medium #{colors}")
-
end
-
-
1
def resolve_label_option(option, record)
-
then: 0
else: 0
return option.call(record) if option.is_a?(Proc)
-
-
option
-
end
-
-
1
def label_badge_colors(color)
-
then: 0
if color.present?
-
"bg-#{color.to_s.downcase}-100 dark:bg-#{color.to_s.downcase}-900/30 text-#{color.to_s.downcase}-700 dark:text-#{color.to_s.downcase}-400"
-
else: 0
else
-
"bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400"
-
end
-
end
-
-
# Renders a form field based on its configuration
-
#
-
# @param f [ActionView::Helpers::FormBuilder] Form builder
-
# @param field [FieldDefinition] Field definition
-
# @param resource [ActiveRecord::Base] The record
-
# @return [String] HTML safe form field
-
1
def render_form_field(f, field, resource)
-
then: 0
else: 0
return if field.if_condition.present? && !field.if_condition.call(resource)
-
then: 0
else: 0
return if field.unless_condition.present? && field.unless_condition.call(resource)
-
-
capture do
-
concat(content_tag(:div, class: "form-group") do
-
concat(f.label(field.name, class: "form-label") do
-
concat(field.label)
-
then: 0
else: 0
concat(content_tag(:span, " *", class: "text-red-500")) if field.required
-
end)
-
-
field_class = "form-input w-full"
-
then: 0
else: 0
field_class += " border-red-500" if resource.errors[field.name].any?
-
-
field_html = case field.type
-
when: 0
when :textarea
-
f.text_area(field.name, class: field_class, rows: field.rows || 4, placeholder: field.placeholder, readonly: field.readonly)
-
when: 0
when :url
-
f.url_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
-
when: 0
when :email
-
f.email_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
-
when: 0
when :number
-
f.number_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
-
when: 0
when :toggle
-
render_toggle_field(f, field, resource)
-
when: 0
when :label
-
label_value = resource.public_send(field.name) rescue nil
-
render_label_badge(label_value, color: field.label_color, size: field.label_size, record: resource)
-
when: 0
when :select
-
then: 0
else: 0
collection = field.collection.is_a?(Proc) ? field.collection.call : field.collection
-
f.select(field.name, collection, { include_blank: true }, class: field_class, disabled: field.readonly)
-
when: 0
when :searchable_select
-
render_searchable_select(f, field, resource)
-
when: 0
when :multi_select, :tags
-
render_multi_select(f, field, resource)
-
when: 0
when :image, :attachment
-
render_file_upload(f, field, resource)
-
when: 0
when :trix, :rich_text
-
f.rich_text_area(field.name, class: "prose dark:prose-invert max-w-none")
-
when: 0
when :markdown
-
f.text_area(field.name, class: "#{field_class} font-mono", rows: field.rows || 12,
-
data: { controller: "markdown-editor" },
-
placeholder: field.placeholder)
-
when: 0
when :file
-
f.file_field(field.name, class: "form-input-file", accept: field.accept)
-
when: 0
when :datetime
-
f.datetime_local_field(field.name, class: field_class, readonly: field.readonly)
-
when: 0
when :date
-
f.date_field(field.name, class: field_class, readonly: field.readonly)
-
when: 0
when :time
-
f.time_field(field.name, class: field_class, readonly: field.readonly)
-
when: 0
when :json
-
render("internal/developer/shared/json_editor_field",
-
f: f,
-
field: field,
-
resource: resource)
-
when: 0
when :code
-
render_code_editor(f, field, resource)
-
else: 0
else
-
f.text_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
-
end
-
-
concat(field_html)
-
-
then: 0
else: 0
if field.help.present?
-
concat(content_tag(:p, field.help, class: "mt-1 text-sm text-slate-500 dark:text-slate-400"))
-
end
-
-
then: 0
else: 0
if resource.errors[field.name].any?
-
concat(content_tag(:p, resource.errors[field.name].first, class: "mt-1 text-sm text-red-600 dark:text-red-400"))
-
end
-
end)
-
end
-
end
-
-
# Renders a toggle switch field (inline, not taking full width)
-
#
-
# @param f [ActionView::Helpers::FormBuilder] Form builder
-
# @param field [FieldDefinition] Field definition
-
# @param resource [ActiveRecord::Base] The record
-
# @return [String] HTML safe toggle switch
-
1
def render_toggle_field(f, field, resource)
-
checked = !!resource.public_send(field.name)
-
param_key = resource.class.model_name.param_key
-
-
content_tag(:div, class: "inline-flex items-center gap-3",
-
data: { controller: "toggle-switch" }) do
-
# Toggle switch button
-
concat(content_tag(:button, type: "button",
-
then: 0
else: 0
class: "relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-slate-800 #{checked ? 'bg-indigo-600' : 'bg-slate-200 dark:bg-slate-700'}",
-
role: "switch",
-
"aria-checked" => checked.to_s,
-
data: {
-
action: "click->toggle-switch#toggle",
-
toggle_switch_target: "button"
-
},
-
disabled: field.readonly) do
-
concat(content_tag(:span, "",
-
then: 0
else: 0
class: "pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out #{checked ? 'translate-x-5' : 'translate-x-0'}",
-
data: { toggle_switch_target: "thumb" }))
-
end)
-
-
# Hidden input for form submission
-
then: 0
else: 0
concat(hidden_field_tag("#{param_key}[#{field.name}]", checked ? "1" : "0",
-
id: "#{param_key}_#{field.name}",
-
data: { toggle_switch_target: "input" }))
-
-
# Status label
-
then: 0
else: 0
concat(content_tag(:span, checked ? "Enabled" : "Disabled",
-
class: "text-sm font-medium text-slate-700 dark:text-slate-300",
-
data: { toggle_switch_target: "label" }))
-
end
-
end
-
-
# Renders a searchable select field
-
#
-
# @param f [ActionView::Helpers::FormBuilder] Form builder
-
# @param field [FieldDefinition] Field definition
-
# @param resource [ActiveRecord::Base] The record
-
# @return [String] HTML safe searchable select
-
1
def render_searchable_select(f, field, resource)
-
param_key = resource.class.model_name.param_key
-
current_value = resource.public_send(field.name)
-
-
# Get options - handle Proc, Array, or String (URL)
-
then: 0
else: 0
collection = field.collection.is_a?(Proc) ? field.collection.call : field.collection
-
-
then: 0
if collection.is_a?(Array)
-
options_json = collection.map { |opt|
-
then: 0
if opt.is_a?(Array)
-
{ value: opt[1], label: opt[0] }
-
else: 0
else
-
{ value: opt, label: opt.to_s.humanize }
-
end
-
}.to_json
-
else: 0
else
-
options_json = "[]"
-
end
-
-
# For display, find the current label
-
then: 0
current_label = if current_value.present? && collection.is_a?(Array)
-
then: 0
else: 0
match = collection.find { |opt| opt.is_a?(Array) ? opt[1].to_s == current_value.to_s : opt.to_s == current_value.to_s }
-
then: 0
else: 0
match.is_a?(Array) ? match[0] : match.to_s
-
else: 0
else
-
current_value
-
end
-
-
content_tag(:div,
-
data: {
-
controller: "searchable-select",
-
searchable_select_options_value: options_json,
-
searchable_select_creatable_value: field.create_url.present?,
-
then: 0
else: 0
searchable_select_search_url_value: collection.is_a?(String) ? collection : ""
-
},
-
class: "relative") do
-
concat(hidden_field_tag("#{param_key}[#{field.name}]", current_value,
-
data: { searchable_select_target: "input" }))
-
# IMPORTANT: this is a UI-only input. Do NOT submit it as a param.
-
# If we submit something like "#{param_key}[#{field.name}]_search", Rack may interpret it as
-
# nested under "#{param_key}[#{field.name}]" and error with:
-
# "expected Hash (got String) for param `#{field.name}`"
-
concat(text_field_tag(nil,
-
current_label,
-
class: "form-input w-full",
-
placeholder: field.placeholder || "Search...",
-
autocomplete: "off",
-
data: {
-
searchable_select_target: "search",
-
action: "input->searchable-select#search focus->searchable-select#open keydown->searchable-select#keydown"
-
}))
-
concat(content_tag(:div, "",
-
class: "absolute z-10 w-full mt-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg hidden max-h-60 overflow-y-auto",
-
data: { searchable_select_target: "dropdown" }))
-
end
-
end
-
-
# Renders a multi-select/tags field
-
#
-
# @param f [ActionView::Helpers::FormBuilder] Form builder
-
# @param field [FieldDefinition] Field definition
-
# @param resource [ActiveRecord::Base] The record
-
# @return [String] HTML safe multi-select
-
1
def render_multi_select(f, field, resource)
-
param_key = resource.class.model_name.param_key
-
-
# Get current values
-
then: 0
current_values = if resource.respond_to?("#{field.name}_list")
-
else: 0
resource.public_send("#{field.name}_list")
-
then: 0
elsif resource.respond_to?(field.name)
-
Array.wrap(resource.public_send(field.name))
-
else: 0
else
-
[]
-
end
-
-
# Get available options
-
then: 0
options = if field.collection.is_a?(Proc)
-
else: 0
field.collection.call
-
then: 0
elsif field.collection.is_a?(Array)
-
field.collection
-
else: 0
else
-
[]
-
end
-
-
# For tags type, we need to use tag_list as the field name with array brackets
-
then: 0
else: 0
field_name = field.type == :tags ? "tag_list" : field.name
-
full_field_name = "#{param_key}[#{field_name}][]"
-
-
content_tag(:div,
-
data: {
-
controller: "tag-select",
-
tag_select_creatable_value: field.create_url.present? || field.type == :tags,
-
tag_select_field_name_value: full_field_name
-
},
-
class: "space-y-2") do
-
# Empty placeholder hidden field (will be replaced when tags are added)
-
concat(hidden_field_tag(full_field_name, "", id: nil, data: { tag_select_target: "placeholder" }))
-
-
# Selected tags display
-
concat(content_tag(:div,
-
class: "flex flex-wrap gap-2 min-h-[2.5rem] p-2 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg",
-
data: { tag_select_target: "tags" }) do
-
current_values.each do |val|
-
concat(content_tag(:span,
-
class: "inline-flex items-center gap-1 px-2 py-1 bg-indigo-100 dark:bg-indigo-900/50 text-indigo-700 dark:text-indigo-300 rounded text-sm") do
-
concat(val.to_s)
-
concat(hidden_field_tag(full_field_name, val, id: nil))
-
concat(button_tag("×",
-
type: "button",
-
class: "text-indigo-500 hover:text-indigo-700 font-bold",
-
data: { action: "tag-select#remove" }))
-
end)
-
end
-
-
# Input for adding new tags
-
concat(text_field_tag(nil, "",
-
class: "flex-1 min-w-[120px] border-none focus:outline-none focus:ring-0 bg-transparent text-sm",
-
placeholder: field.placeholder || "Add tag...",
-
autocomplete: "off",
-
data: {
-
tag_select_target: "input",
-
action: "keydown->tag-select#keydown input->tag-select#search"
-
}))
-
end)
-
-
# Suggestions dropdown
-
then: 0
else: 0
if options.any?
-
concat(content_tag(:div,
-
class: "hidden border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 shadow-lg max-h-48 overflow-y-auto",
-
data: { tag_select_target: "dropdown" }) do
-
options.each do |opt|
-
then: 0
else: 0
label, value = opt.is_a?(Array) ? [ opt[0], opt[1] ] : [ opt, opt ]
-
concat(content_tag(:button, label,
-
type: "button",
-
class: "block w-full text-left px-3 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-700",
-
data: {
-
action: "tag-select#select",
-
value: value
-
}))
-
end
-
end)
-
end
-
end
-
end
-
-
# Renders a file upload field with preview
-
#
-
# @param f [ActionView::Helpers::FormBuilder] Form builder
-
# @param field [FieldDefinition] Field definition
-
# @param resource [ActiveRecord::Base] The record
-
# @return [String] HTML safe file upload
-
1
def render_file_upload(f, field, resource)
-
then: 0
else: 0
attachment = resource.respond_to?(field.name) ? resource.public_send(field.name) : nil
-
has_attachment = attachment.respond_to?(:attached?) && attachment.attached?
-
-
is_image = field.type == :image ||
-
(field.accept.present? && field.accept.include?("image"))
-
-
# Build existing URL for preview if available
-
then: 0
else: 0
existing_url = has_attachment && is_image ? url_for(attachment.variant(resize_to_limit: [ 300, 300 ])) : nil
-
-
content_tag(:div,
-
data: {
-
controller: "file-upload",
-
then: 0
else: 0
file_upload_accept_value: field.accept || (is_image ? "image/*" : "*/*"),
-
file_upload_preview_value: field.type == :image,
-
file_upload_existing_url_value: existing_url
-
},
-
class: "space-y-3") do
-
# Current file preview
-
then: 0
if has_attachment && is_image
-
concat(content_tag(:div, class: "relative inline-block") do
-
concat(image_tag(existing_url,
-
class: "max-w-[200px] max-h-[150px] rounded-lg border border-slate-200 dark:border-slate-700 object-cover",
-
data: { file_upload_target: "imagePreview" }))
-
concat(button_tag("×", type: "button",
-
class: "absolute -top-2 -right-2 w-6 h-6 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center text-sm",
-
data: {
-
file_upload_target: "removeButton",
-
action: "file-upload#remove"
-
}))
-
else: 0
end)
-
then: 0
elsif has_attachment
-
concat(content_tag(:div,
-
class: "flex items-center gap-2 p-3 bg-slate-50 dark:bg-slate-900 rounded-lg",
-
data: { file_upload_target: "filename" }) do
-
concat('<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>'.html_safe)
-
concat(content_tag(:span, attachment.filename.to_s, class: "text-sm font-medium text-slate-600 dark:text-slate-300"))
-
concat(content_tag(:span, "(#{number_to_human_size(attachment.byte_size)})", class: "text-xs text-slate-400"))
-
end)
-
else
-
else: 0
# Hidden image preview for new uploads
-
concat(image_tag("",
-
class: "hidden max-w-[200px] max-h-[150px] rounded-lg border border-slate-200 dark:border-slate-700 object-cover",
-
data: { file_upload_target: "imagePreview" }))
-
concat(content_tag(:div, "",
-
class: "hidden",
-
data: { file_upload_target: "filename" }))
-
end
-
-
# Dropzone / Upload area
-
concat(content_tag(:div,
-
class: "relative border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg hover:border-indigo-400 dark:hover:border-indigo-500 transition-colors",
-
data: { file_upload_target: "dropzone" }) do
-
# Hidden file input
-
concat(f.file_field(field.name,
-
class: "sr-only",
-
id: "#{field.name}_input",
-
then: 0
else: 0
accept: field.accept || (is_image ? "image/*" : nil),
-
data: {
-
file_upload_target: "input",
-
action: "change->file-upload#preview"
-
}))
-
-
# Styled upload label
-
concat(content_tag(:label,
-
for: "#{field.name}_input",
-
class: "flex flex-col items-center justify-center w-full py-6 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-900/50 rounded-lg transition-colors") do
-
concat('<svg class="w-8 h-8 text-slate-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>'.html_safe)
-
concat(content_tag(:span, "Click to upload or drag and drop", class: "text-sm text-slate-500 dark:text-slate-400"))
-
then: 0
if is_image
-
else: 0
concat(content_tag(:span, "PNG, JPG, WebP up to 10MB", class: "text-xs text-slate-400 mt-1"))
-
then: 0
else: 0
elsif field.accept.present?
-
concat(content_tag(:span, field.accept.gsub(",", ", "), class: "text-xs text-slate-400 mt-1"))
-
end
-
end)
-
end)
-
-
# Progress indicator (hidden by default)
-
concat(content_tag(:div, "",
-
class: "hidden",
-
data: { file_upload_target: "progress" }))
-
end
-
end
-
-
# Renders turn messages preview (for assistant turns)
-
#
-
# @param resource [ActiveRecord::Base] The turn record
-
# @return [String] HTML safe messages preview
-
1
def render_turn_messages_preview(resource)
-
then: 0
else: 0
user_msg = resource.respond_to?(:user_message) ? resource.user_message : nil
-
then: 0
else: 0
asst_msg = resource.respond_to?(:assistant_message) ? resource.assistant_message : nil
-
-
content_tag(:div, class: "space-y-4") do
-
# User message
-
then: 0
else: 0
if user_msg
-
concat(content_tag(:div, class: "rounded-lg border p-4 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800") do
-
concat(content_tag(:div, class: "flex items-center gap-2 mb-2") do
-
concat('<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>'.html_safe)
-
concat(content_tag(:span, "User", class: "text-sm font-medium text-slate-700 dark:text-slate-200"))
-
end)
-
concat(content_tag(:div, simple_format(h(user_msg.content.to_s)), class: "prose dark:prose-invert prose-sm max-w-none"))
-
end)
-
end
-
-
# Assistant message
-
then: 0
else: 0
if asst_msg
-
concat(content_tag(:div, class: "rounded-lg border p-4 bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800") do
-
concat(content_tag(:div, class: "flex items-center gap-2 mb-2") do
-
concat('<svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/></svg>'.html_safe)
-
concat(content_tag(:span, "Assistant", class: "text-sm font-medium text-slate-700 dark:text-slate-200"))
-
end)
-
concat(content_tag(:div, simple_format(h(asst_msg.content.to_s)), class: "prose dark:prose-invert prose-sm max-w-none"))
-
end)
-
end
-
-
else: 0
then: 0
unless user_msg || asst_msg
-
concat(content_tag(:p, "No messages found", class: "text-slate-400 italic text-sm"))
-
end
-
end
-
end
-
-
# Renders a code editor field
-
#
-
# @param f [ActionView::Helpers::FormBuilder] Form builder
-
# @param field [FieldDefinition] Field definition
-
# @param resource [ActiveRecord::Base] The record
-
# @return [String] HTML safe code editor
-
1
def render_code_editor(f, field, resource)
-
content_tag(:div, class: "relative", data: { controller: "code-editor" }) do
-
f.text_area(field.name,
-
class: "w-full font-mono text-sm bg-slate-900 text-slate-100 p-4 rounded-lg border border-slate-700 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500",
-
rows: field.rows || 12,
-
placeholder: field.placeholder,
-
data: { code_editor_target: "textarea" })
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module Internal
-
1
module Developer
-
# App-specific renderers for the internal developer portal.
-
#
-
# These are intentionally kept out of the core admin suite helper so the
-
# suite can be extracted into a reusable engine/gem.
-
1
module CustomRenderersHelper
-
# Renders a billing debug snapshot for a user record.
-
#
-
# Intended for the internal developer portal.
-
#
-
# @param resource [ActiveRecord::Base] Expected to be a User
-
# @return [String]
-
1
def render_billing_debug_snapshot(resource)
-
else: 0
then: 0
unless resource.is_a?(User)
-
return content_tag(:p, "Billing debug snapshot is only supported for User records.", class: "text-slate-500 italic text-sm")
-
end
-
-
snapshot = Billing::DebugSnapshotService.new(user: resource).run
-
render_json_block(snapshot)
-
rescue StandardError => e
-
content_tag(:div, class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4") do
-
concat(content_tag(:p, "Failed to build billing debug snapshot.", class: "text-sm font-medium text-red-700 dark:text-red-300"))
-
concat(content_tag(:p, e.message.to_s, class: "mt-1 text-sm text-red-600 dark:text-red-400 font-mono"))
-
end
-
end
-
-
# Renders provider-native payloads for Ai::LlmApiLog in a prominent, copy-friendly format.
-
#
-
# Reads from:
-
# - request_payload["provider_request"]
-
# - response_payload["provider_response"] / ["provider_error_response"]
-
#
-
# @param resource [ActiveRecord::Base]
-
# @param kind [Symbol] :provider_request, :provider_response, :provider_error_response
-
# @return [String]
-
1
def render_llm_provider_payload(resource, kind:)
-
else: 0
then: 0
return content_tag(:p, "Not available", class: "text-slate-400 italic text-sm") unless resource.respond_to?(:request_payload) && resource.respond_to?(:response_payload)
-
-
request_payload = resource.request_payload || {}
-
response_payload = resource.response_payload || {}
-
-
value =
-
case kind.to_sym
-
when: 0
when :provider_request
-
request_payload["provider_request"] || request_payload[:provider_request]
-
when: 0
when :provider_response
-
response_payload["provider_response"] || response_payload[:provider_response]
-
when: 0
when :provider_error_response
-
response_payload["provider_error_response"] || response_payload[:provider_error_response]
-
else: 0
else
-
nil
-
end
-
-
then: 0
else: 0
if value.blank?
-
hint =
-
then: 0
if kind.to_sym == :provider_error_response
-
"No provider error response captured."
-
else: 0
else
-
"No provider payload captured."
-
end
-
return content_tag(:div, class: "text-sm text-slate-500 dark:text-slate-400") do
-
concat(content_tag(:p, hint, class: "italic"))
-
concat(content_tag(:p, "Older logs (or synthetic logs) may not include raw provider payloads.", class: "text-xs mt-1"))
-
end
-
end
-
-
# Render hashes/arrays as highlighted JSON. Strings are attempted as JSON, else plain.
-
then: 0
if value.is_a?(Hash) || value.is_a?(Array)
-
render_json_block(value)
-
else: 0
else
-
str = value.to_s
-
then: 0
if str.strip.start_with?("{", "[")
-
begin
-
render_json_block(JSON.parse(str))
-
rescue JSON::ParserError
-
render_text_block(str)
-
end
-
else: 0
else
-
render_text_block(str)
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module Internal
-
1
module Developer
-
# Helper methods for dashboard cards and health metrics
-
1
module DashboardHelper
-
# Renders a stat card
-
#
-
# @param label [String] The label text
-
# @param value [String, Integer] The value to display
-
# @param options [Hash] Additional options
-
# @option options [Symbol] :color Color theme (:default, :green, :red, :amber, :cyan, :violet)
-
# @option options [String] :subtitle Additional text below the value
-
# @option options [String] :trend Trend indicator (e.g., "+5%", "-2%")
-
# @option options [Symbol] :trend_direction :up, :down, or :neutral
-
# @return [String] HTML for the stat card
-
1
def stat_card(label, value, **options)
-
color = options[:color] || :default
-
color_classes = stat_color_classes(color)
-
-
content_tag(:div, class: "#{color_classes[:bg]} rounded-xl p-5 #{color_classes[:border]}") do
-
concat(content_tag(:div, class: "flex items-center justify-between") do
-
concat(content_tag(:div, value.to_s, class: "text-3xl font-bold #{color_classes[:text]}"))
-
then: 0
else: 0
if options[:trend].present?
-
when: 0
trend_class = case options[:trend_direction]
-
when: 0
when :up then "text-green-500"
-
else: 0
when :down then "text-red-500"
-
else "text-slate-400"
-
end
-
concat(content_tag(:span, options[:trend], class: "text-sm font-medium #{trend_class}"))
-
end
-
end)
-
concat(content_tag(:div, label, class: "text-sm #{color_classes[:label]} mt-1"))
-
then: 0
else: 0
if options[:subtitle].present?
-
concat(content_tag(:div, options[:subtitle], class: "text-xs #{color_classes[:label]} mt-1 opacity-75"))
-
end
-
end
-
end
-
-
# Renders a health status card
-
#
-
# @param title [String] The system name
-
# @param status [Symbol] :healthy, :degraded, :critical, or :unknown
-
# @param metrics [Hash] Key-value pairs of metrics to display
-
# @return [String] HTML for the health card
-
1
def health_card(title, status, metrics: {})
-
status_config = health_status_config(status)
-
-
content_tag(:div, class: "bg-white dark:bg-slate-800 rounded-xl border #{status_config[:border]} overflow-hidden") do
-
# Header
-
concat(content_tag(:div, class: "px-4 py-3 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between") do
-
concat(content_tag(:h3, title, class: "font-semibold text-slate-900 dark:text-white"))
-
concat(content_tag(:span, class: "flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium #{status_config[:badge]}") do
-
concat(content_tag(:span, "", class: "w-2 h-2 rounded-full #{status_config[:dot]}"))
-
concat(status.to_s.humanize)
-
end)
-
end)
-
-
# Metrics
-
then: 0
else: 0
if metrics.any?
-
concat(content_tag(:div, class: "p-4 grid grid-cols-2 gap-3") do
-
metrics.each do |key, val|
-
concat(content_tag(:div) do
-
concat(content_tag(:div, val.to_s, class: "text-lg font-semibold text-slate-900 dark:text-white"))
-
concat(content_tag(:div, key.to_s.humanize, class: "text-xs text-slate-500 dark:text-slate-400"))
-
end)
-
end
-
end)
-
end
-
end
-
end
-
-
# Renders a recent items list card
-
#
-
# @param title [String] Card title
-
# @param items [Array] Array of records to display
-
# @param options [Hash] Display options
-
# @option options [String] :path_helper Helper method name for item links
-
# @option options [Symbol] :title_field Field to use for item title
-
# @option options [Symbol] :subtitle_field Field to use for subtitle
-
# @option options [Symbol] :badge_field Field to use for status badge
-
# @option options [String] :empty_message Message when no items
-
# @option options [String] :view_all_path Link to view all items
-
# @return [String] HTML for the recent items card
-
1
def recent_items_card(title, items, **options)
-
content_tag(:div, class: "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden") do
-
# Header
-
concat(content_tag(:div, class: "px-4 py-3 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between") do
-
concat(content_tag(:h3, title, class: "font-semibold text-slate-900 dark:text-white"))
-
then: 0
else: 0
if options[:view_all_path].present?
-
concat(link_to("View all →", options[:view_all_path],
-
class: "text-sm text-indigo-600 dark:text-indigo-400 hover:underline"))
-
end
-
end)
-
-
# Items list
-
then: 0
if items.any?
-
concat(content_tag(:ul, class: "divide-y divide-slate-100 dark:divide-slate-700") do
-
items.each do |item|
-
concat(render_recent_item(item, options))
-
end
-
end)
-
else: 0
else
-
concat(content_tag(:div, class: "p-4 text-center text-sm text-slate-500 dark:text-slate-400") do
-
options[:empty_message] || "No recent items"
-
end)
-
end
-
end
-
end
-
-
# Renders a mini chart card (sparkline-style)
-
#
-
# @param title [String] Card title
-
# @param data [Array<Hash>] Array of { label:, value: } hashes
-
# @param options [Hash] Display options
-
# @option options [Symbol] :color Color theme
-
# @option options [String] :total Total value to display
-
# @return [String] HTML for the chart card
-
1
def chart_card(title, data, **options)
-
max_value = data.map { |d| d[:value].to_f }.max || 1
-
then: 0
else: 0
max_value = 1 if max_value.zero? # Avoid division by zero
-
bar_color = chart_bar_color(options[:color])
-
-
content_tag(:div, class: "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4") do
-
# Header
-
concat(content_tag(:div, class: "flex items-center justify-between mb-4") do
-
concat(content_tag(:h3, title, class: "font-semibold text-slate-900 dark:text-white"))
-
then: 0
else: 0
if options[:total].present?
-
concat(content_tag(:span, options[:total], class: "text-2xl font-bold text-slate-900 dark:text-white"))
-
end
-
end)
-
-
# Bar chart
-
concat(content_tag(:div, class: "flex items-end gap-1 h-16") do
-
data.each do |d|
-
value = d[:value].to_f
-
then: 0
else: 0
height_float = max_value > 0 ? ((value / max_value) * 100) : 0.0
-
# Check for NaN or infinite before rounding
-
then: 0
else: 0
height_float = 0.0 if height_float.nan? || height_float.infinite?
-
height = height_float.round
-
concat(content_tag(:div, class: "flex-1 flex flex-col items-center gap-1") do
-
concat(content_tag(:div, "",
-
class: "w-full rounded-t #{bar_color} transition-all",
-
style: "height: #{height}%",
-
title: "#{d[:label]}: #{d[:value]}"))
-
end)
-
end
-
end)
-
-
# Labels
-
concat(content_tag(:div, class: "flex gap-1 mt-2") do
-
data.each do |d|
-
concat(content_tag(:div, d[:label].to_s.first(3),
-
class: "flex-1 text-center text-xs text-slate-400 dark:text-slate-500"))
-
end
-
end)
-
end
-
end
-
-
# Renders an activity timeline card
-
#
-
# @param title [String] Card title
-
# @param activities [Array<Hash>] Array of { title:, time:, icon:, color: }
-
# @return [String] HTML for the activity card
-
1
def activity_card(title, activities)
-
content_tag(:div, class: "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden") do
-
concat(content_tag(:div, class: "px-4 py-3 border-b border-slate-200 dark:border-slate-700") do
-
content_tag(:h3, title, class: "font-semibold text-slate-900 dark:text-white")
-
end)
-
-
then: 0
if activities.any?
-
concat(content_tag(:div, class: "p-4 space-y-4") do
-
activities.each do |activity|
-
concat(render_activity_item(activity))
-
end
-
end)
-
else: 0
else
-
concat(content_tag(:div, class: "p-4 text-center text-sm text-slate-500 dark:text-slate-400") do
-
"No recent activity"
-
end)
-
end
-
end
-
end
-
-
1
private
-
-
1
def stat_color_classes(color)
-
case color
-
when: 0
when :green
-
{
-
bg: "bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20",
-
border: "border border-green-200 dark:border-green-800/50",
-
text: "text-green-700 dark:text-green-400",
-
label: "text-green-600 dark:text-green-500"
-
}
-
when: 0
when :red
-
{
-
bg: "bg-gradient-to-br from-red-50 to-red-100 dark:from-red-900/20 dark:to-red-800/20",
-
border: "border border-red-200 dark:border-red-800/50",
-
text: "text-red-700 dark:text-red-400",
-
label: "text-red-600 dark:text-red-500"
-
}
-
when: 0
when :amber
-
{
-
bg: "bg-gradient-to-br from-amber-50 to-amber-100 dark:from-amber-900/20 dark:to-amber-800/20",
-
border: "border border-amber-200 dark:border-amber-800/50",
-
text: "text-amber-700 dark:text-amber-400",
-
label: "text-amber-600 dark:text-amber-500"
-
}
-
when: 0
when :cyan
-
{
-
bg: "bg-gradient-to-br from-cyan-50 to-cyan-100 dark:from-cyan-900/20 dark:to-cyan-800/20",
-
border: "border border-cyan-200 dark:border-cyan-800/50",
-
text: "text-cyan-700 dark:text-cyan-400",
-
label: "text-cyan-600 dark:text-cyan-500"
-
}
-
when: 0
when :violet
-
{
-
bg: "bg-gradient-to-br from-violet-50 to-violet-100 dark:from-violet-900/20 dark:to-violet-800/20",
-
border: "border border-violet-200 dark:border-violet-800/50",
-
text: "text-violet-700 dark:text-violet-400",
-
label: "text-violet-600 dark:text-violet-500"
-
}
-
else: 0
else
-
{
-
bg: "bg-white dark:bg-slate-800",
-
border: "border border-slate-200 dark:border-slate-700",
-
text: "text-slate-900 dark:text-white",
-
label: "text-slate-500 dark:text-slate-400"
-
}
-
end
-
end
-
-
1
def health_status_config(status)
-
case status
-
when: 0
when :healthy
-
{
-
border: "border-green-200 dark:border-green-800/50",
-
badge: "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400",
-
dot: "bg-green-500 animate-pulse"
-
}
-
when: 0
when :degraded
-
{
-
border: "border-amber-200 dark:border-amber-800/50",
-
badge: "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400",
-
dot: "bg-amber-500 animate-pulse"
-
}
-
when: 0
when :critical
-
{
-
border: "border-red-200 dark:border-red-800/50",
-
badge: "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400",
-
dot: "bg-red-500 animate-pulse"
-
}
-
else: 0
else
-
{
-
border: "border-slate-200 dark:border-slate-700",
-
badge: "bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400",
-
dot: "bg-slate-400"
-
}
-
end
-
end
-
-
1
def render_recent_item(item, options)
-
title_value = item.public_send(options[:title_field] || :id)
-
# Handle ActiveRecord associations - try to get a display name
-
then: 0
title = if title_value.respond_to?(:name)
-
else: 0
title_value.name
-
then: 0
elsif title_value.respond_to?(:title)
-
else: 0
title_value.title
-
then: 0
elsif title_value.respond_to?(:email_address)
-
title_value.email_address
-
else: 0
else
-
title_value.to_s
-
end
-
-
then: 0
else: 0
subtitle_value = options[:subtitle_field] ? item.public_send(options[:subtitle_field]) : nil
-
then: 0
subtitle = if subtitle_value.respond_to?(:name)
-
else: 0
subtitle_value.name
-
then: 0
elsif subtitle_value.respond_to?(:title)
-
else: 0
subtitle_value.title
-
then: 0
elsif subtitle_value.respond_to?(:email_address)
-
subtitle_value.email_address
-
else: 0
else
-
then: 0
else: 0
subtitle_value&.to_s
-
end
-
-
then: 0
else: 0
badge = options[:badge_field] ? item.public_send(options[:badge_field]) : nil
-
then: 0
path = if options[:path_helper]
-
then: 0
if options[:path_helper].respond_to?(:call)
-
options[:path_helper].call(item)
-
else: 0
else
-
send(options[:path_helper], item)
-
end
-
else: 0
else
-
nil
-
end
-
-
content_tag(:li, class: "px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors") do
-
then: 0
else: 0
wrapper = path ? link_to(path, class: "block") : content_tag(:div)
-
-
then: 0
if path
-
link_to(path, class: "flex items-center justify-between") do
-
concat(render_recent_item_content(title, subtitle, item))
-
then: 0
else: 0
if badge
-
concat(content_tag(:span, badge.to_s.humanize,
-
class: "px-2 py-1 text-xs rounded-full bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400"))
-
end
-
end
-
else: 0
else
-
content_tag(:div, class: "flex items-center justify-between") do
-
concat(render_recent_item_content(title, subtitle, item))
-
then: 0
else: 0
if badge
-
concat(content_tag(:span, badge.to_s.humanize,
-
class: "px-2 py-1 text-xs rounded-full bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400"))
-
end
-
end
-
end
-
end
-
end
-
-
1
def render_recent_item_content(title, subtitle, item)
-
content_tag(:div) do
-
concat(content_tag(:div, title.to_s.truncate(40), class: "text-sm font-medium text-slate-900 dark:text-white"))
-
then: 0
if subtitle.present?
-
else: 0
concat(content_tag(:div, subtitle.to_s, class: "text-xs text-slate-500 dark:text-slate-400"))
-
then: 0
else: 0
elsif item.respond_to?(:created_at)
-
concat(content_tag(:div, time_ago_in_words(item.created_at) + " ago",
-
class: "text-xs text-slate-500 dark:text-slate-400"))
-
end
-
end
-
end
-
-
1
def render_activity_item(activity)
-
icon_classes = activity_icon_classes(activity[:color])
-
-
content_tag(:div, class: "flex gap-3") do
-
concat(content_tag(:div, class: "flex-shrink-0 w-8 h-8 rounded-full #{icon_classes[:bg]} flex items-center justify-center") do
-
content_tag(:span, activity[:icon] || "•", class: icon_classes[:text])
-
end)
-
concat(content_tag(:div, class: "flex-1 min-w-0") do
-
concat(content_tag(:p, activity[:title], class: "text-sm text-slate-900 dark:text-white"))
-
concat(content_tag(:p, activity[:time], class: "text-xs text-slate-500 dark:text-slate-400"))
-
end)
-
end
-
end
-
-
1
def chart_bar_color(color)
-
when: 0
case color
-
when: 0
when :amber then "bg-amber-500 dark:bg-amber-400"
-
when: 0
when :green then "bg-green-500 dark:bg-green-400"
-
when: 0
when :red then "bg-red-500 dark:bg-red-400"
-
when: 0
when :cyan then "bg-cyan-500 dark:bg-cyan-400"
-
when: 0
when :violet then "bg-violet-500 dark:bg-violet-400"
-
else: 0
when :indigo then "bg-indigo-500 dark:bg-indigo-400"
-
else "bg-indigo-500 dark:bg-indigo-400"
-
end
-
end
-
-
1
def activity_icon_classes(color)
-
case color
-
when: 0
when :green
-
{ bg: "bg-green-100 dark:bg-green-900/30", text: "text-green-600 dark:text-green-400" }
-
when: 0
when :red
-
{ bg: "bg-red-100 dark:bg-red-900/30", text: "text-red-600 dark:text-red-400" }
-
when: 0
when :amber
-
{ bg: "bg-amber-100 dark:bg-amber-900/30", text: "text-amber-600 dark:text-amber-400" }
-
when: 0
when :cyan
-
{ bg: "bg-cyan-100 dark:bg-cyan-900/30", text: "text-cyan-600 dark:text-cyan-400" }
-
when: 0
when :violet
-
{ bg: "bg-violet-100 dark:bg-violet-900/30", text: "text-violet-600 dark:text-violet-400" }
-
else: 0
else
-
{ bg: "bg-slate-100 dark:bg-slate-700", text: "text-slate-600 dark:text-slate-400" }
-
end
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
# Helper methods for interview application views
-
#
-
# Provides consistent styling classes for badges, status indicators,
-
# and other UI elements across all interview application views.
-
1
module InterviewApplicationsHelper
-
# Event type icons (SVG paths)
-
1
EVENT_ICONS = {
-
applied: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z",
-
interview: "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z",
-
interview_scheduled: "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z",
-
interview_completed: "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z",
-
feedback: "M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z",
-
feedback_received: "M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z",
-
email: "M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z",
-
offer: "M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
-
rejection: "M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z",
-
rejected: "M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z",
-
status_change: "M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15",
-
default: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
-
}.freeze
-
-
# Returns styling data for timeline events
-
#
-
# @param event_type [String, Symbol] The event type
-
# @return [Hash] Hash containing :icon and :classes for various elements
-
1
def timeline_event_styling(event_type)
-
type = event_type.to_s.to_sym
-
{
-
icon: EVENT_ICONS[type] || EVENT_ICONS[:default],
-
classes: timeline_event_classes(type)
-
}
-
end
-
-
# Returns Tailwind classes for pipeline stage badges
-
#
-
# @param stage [String, Symbol] The pipeline stage
-
# @return [String] Tailwind CSS classes
-
1
def pipeline_stage_badge_classes(stage)
-
then: 0
else: 0
case stage&.to_sym
-
when: 0
when :applied
-
"bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
-
when: 0
when :screening
-
"bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400"
-
when: 0
when :interviewing
-
"bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-400"
-
when: 0
when :offer
-
"bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400"
-
when: 0
when :closed
-
"bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
-
else: 0
else
-
"bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
-
end
-
end
-
-
# Returns Tailwind classes for application status badges
-
#
-
# @param status [String, Symbol, nil] The application status
-
# @return [String] Tailwind CSS classes
-
1
def application_status_badge_classes(status)
-
then: 0
else: 0
case status&.to_sym
-
when: 0
when :active
-
"bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400"
-
when: 0
when :accepted
-
"bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400"
-
when: 0
when :rejected
-
"bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400"
-
when: 0
when :archived
-
"bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
-
else: 0
else
-
"bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400"
-
end
-
end
-
-
# Returns styling data for match score display
-
#
-
# Used in prep_snapshot to style the match score card based on
-
# the fit assessment score.
-
#
-
# @param score [Integer, nil] The fit assessment score (0-100)
-
# @return [Hash] Hash containing :label, :color, and CSS class mappings
-
1
def match_score_styling(score)
-
label, color = match_score_label_and_color(score)
-
-
{
-
label: label,
-
color: color,
-
classes: match_score_classes_for_color(color)
-
}
-
end
-
-
# Returns just the label and color for a match score
-
#
-
# @param score [Integer, nil] The fit assessment score (0-100)
-
# @return [Array<String, String>] [label, color]
-
1
def match_score_label_and_color(score)
-
then: 0
if score.nil?
-
else: 0
[ "Not assessed", "slate" ]
-
then: 0
elsif score >= 80
-
else: 0
[ "Strong match", "emerald" ]
-
then: 0
elsif score >= 50
-
[ "Partial match", "amber" ]
-
else: 0
else
-
[ "Stretch role", "rose" ]
-
end
-
end
-
-
# Returns available pipeline stage transitions for an application
-
#
-
# Checks the AASM state machine to determine which transitions
-
# are valid from the current state.
-
#
-
# @param application [InterviewApplication] The application to check
-
# @return [Array<Hash>] Array of hashes with :stage and :label keys
-
1
def available_pipeline_stage_transitions(application)
-
InterviewApplication::PIPELINE_STAGES.filter_map do |stage|
-
then: 0
else: 0
next if stage.to_s == application.pipeline_stage
-
event = pipeline_stage_event_for(stage)
-
else: 0
then: 0
next unless event && application.aasm(:pipeline_stage).may_fire_event?(event)
-
{ stage: stage, label: stage.to_s.titleize }
-
end
-
end
-
-
# Maps a pipeline stage to its corresponding AASM event
-
#
-
# @param stage [Symbol] The target pipeline stage
-
# @return [Symbol, nil] The AASM event name or nil if not found
-
1
def pipeline_stage_event_for(stage)
-
{
-
screening: :move_to_screening,
-
interviewing: :move_to_interviewing,
-
offer: :move_to_offer,
-
closed: :move_to_closed,
-
applied: :move_to_applied
-
}[stage]
-
end
-
-
# Returns icon color class for a pipeline stage
-
#
-
# Used for styling icons in the pipeline stage actions menu.
-
#
-
# @param stage [Symbol] The pipeline stage
-
# @return [String] Tailwind CSS color class
-
1
def pipeline_stage_icon_color(stage)
-
case stage
-
when: 0
when :applied
-
"text-gray-500"
-
when: 0
when :screening
-
"text-blue-500"
-
when: 0
when :interviewing
-
"text-purple-500"
-
when: 0
when :offer
-
"text-green-500"
-
when: 0
when :closed
-
"text-gray-500"
-
else: 0
else
-
"text-gray-500"
-
end
-
end
-
-
1
private
-
-
# Returns CSS class mappings for a timeline event type
-
#
-
# All classes are explicitly written out to ensure Tailwind
-
# can detect them at build time (no dynamic string interpolation).
-
#
-
# @param event_type [Symbol] The event type
-
# @return [Hash] CSS class mappings for various elements
-
1
def timeline_event_classes(event_type)
-
case event_type
-
when: 0
when :applied
-
{
-
dot_bg: "bg-blue-100 dark:bg-blue-900/40",
-
dot_border: "border-blue-500",
-
accent: "bg-blue-500",
-
icon_bg: "bg-blue-100 dark:bg-blue-900/30",
-
icon_text: "text-blue-600 dark:text-blue-400",
-
badge_bg: "bg-blue-100 dark:bg-blue-900/30",
-
badge_text: "text-blue-700 dark:text-blue-300"
-
}
-
when: 0
when :interview, :interview_scheduled
-
{
-
dot_bg: "bg-violet-100 dark:bg-violet-900/40",
-
dot_border: "border-violet-500",
-
accent: "bg-violet-500",
-
icon_bg: "bg-violet-100 dark:bg-violet-900/30",
-
icon_text: "text-violet-600 dark:text-violet-400",
-
badge_bg: "bg-violet-100 dark:bg-violet-900/30",
-
badge_text: "text-violet-700 dark:text-violet-300"
-
}
-
when: 0
when :interview_completed
-
{
-
dot_bg: "bg-emerald-100 dark:bg-emerald-900/40",
-
dot_border: "border-emerald-500",
-
accent: "bg-emerald-500",
-
icon_bg: "bg-emerald-100 dark:bg-emerald-900/30",
-
icon_text: "text-emerald-600 dark:text-emerald-400",
-
badge_bg: "bg-emerald-100 dark:bg-emerald-900/30",
-
badge_text: "text-emerald-700 dark:text-emerald-300"
-
}
-
when: 0
when :feedback, :feedback_received
-
{
-
dot_bg: "bg-amber-100 dark:bg-amber-900/40",
-
dot_border: "border-amber-500",
-
accent: "bg-amber-500",
-
icon_bg: "bg-amber-100 dark:bg-amber-900/30",
-
icon_text: "text-amber-600 dark:text-amber-400",
-
badge_bg: "bg-amber-100 dark:bg-amber-900/30",
-
badge_text: "text-amber-700 dark:text-amber-300"
-
}
-
when: 0
when :email
-
{
-
dot_bg: "bg-cyan-100 dark:bg-cyan-900/40",
-
dot_border: "border-cyan-500",
-
accent: "bg-cyan-500",
-
icon_bg: "bg-cyan-100 dark:bg-cyan-900/30",
-
icon_text: "text-cyan-600 dark:text-cyan-400",
-
badge_bg: "bg-cyan-100 dark:bg-cyan-900/30",
-
badge_text: "text-cyan-700 dark:text-cyan-300"
-
}
-
when: 0
when :offer
-
{
-
dot_bg: "bg-emerald-100 dark:bg-emerald-900/40",
-
dot_border: "border-emerald-500",
-
accent: "bg-emerald-500",
-
icon_bg: "bg-emerald-100 dark:bg-emerald-900/30",
-
icon_text: "text-emerald-600 dark:text-emerald-400",
-
badge_bg: "bg-emerald-100 dark:bg-emerald-900/30",
-
badge_text: "text-emerald-700 dark:text-emerald-300"
-
}
-
when: 0
when :rejection, :rejected
-
{
-
dot_bg: "bg-rose-100 dark:bg-rose-900/40",
-
dot_border: "border-rose-500",
-
accent: "bg-rose-500",
-
icon_bg: "bg-rose-100 dark:bg-rose-900/30",
-
icon_text: "text-rose-600 dark:text-rose-400",
-
badge_bg: "bg-rose-100 dark:bg-rose-900/30",
-
badge_text: "text-rose-700 dark:text-rose-300"
-
}
-
when: 0
when :status_change
-
{
-
dot_bg: "bg-indigo-100 dark:bg-indigo-900/40",
-
dot_border: "border-indigo-500",
-
accent: "bg-indigo-500",
-
icon_bg: "bg-indigo-100 dark:bg-indigo-900/30",
-
icon_text: "text-indigo-600 dark:text-indigo-400",
-
badge_bg: "bg-indigo-100 dark:bg-indigo-900/30",
-
badge_text: "text-indigo-700 dark:text-indigo-300"
-
}
-
else: 0
else # gray/default
-
{
-
dot_bg: "bg-gray-100 dark:bg-gray-700",
-
dot_border: "border-gray-400 dark:border-gray-500",
-
accent: "bg-gray-400 dark:bg-gray-500",
-
icon_bg: "bg-gray-100 dark:bg-gray-700",
-
icon_text: "text-gray-600 dark:text-gray-400",
-
badge_bg: "bg-gray-100 dark:bg-gray-700",
-
badge_text: "text-gray-700 dark:text-gray-300"
-
}
-
end
-
end
-
-
# Returns CSS class mappings for a given match color
-
#
-
# All classes are explicitly written out to ensure Tailwind
-
# can detect them at build time (no dynamic string interpolation).
-
#
-
# @param color [String] The color name (emerald, amber, rose, slate)
-
# @return [Hash] CSS class mappings for various elements
-
1
def match_score_classes_for_color(color)
-
case color
-
when: 0
when "emerald"
-
{
-
gradient: "from-emerald-100 dark:from-emerald-900/20",
-
icon_bg: "bg-emerald-100 dark:bg-emerald-500/20",
-
icon_text: "text-emerald-600 dark:text-emerald-300",
-
badge_bg: "bg-emerald-100 dark:bg-emerald-900/30",
-
badge_text: "text-emerald-700 dark:text-emerald-300",
-
dot: "bg-emerald-500"
-
}
-
when: 0
when "amber"
-
{
-
gradient: "from-amber-100 dark:from-amber-900/20",
-
icon_bg: "bg-amber-100 dark:bg-amber-500/20",
-
icon_text: "text-amber-600 dark:text-amber-300",
-
badge_bg: "bg-amber-100 dark:bg-amber-900/30",
-
badge_text: "text-amber-700 dark:text-amber-300",
-
dot: "bg-amber-500"
-
}
-
when: 0
when "rose"
-
{
-
gradient: "from-rose-100 dark:from-rose-900/20",
-
icon_bg: "bg-rose-100 dark:bg-rose-500/20",
-
icon_text: "text-rose-600 dark:text-rose-300",
-
badge_bg: "bg-rose-100 dark:bg-rose-900/30",
-
badge_text: "text-rose-700 dark:text-rose-300",
-
dot: "bg-rose-500"
-
}
-
else: 0
else # slate (Not assessed)
-
{
-
gradient: "from-slate-200 dark:from-slate-600/20",
-
icon_bg: "bg-slate-200 dark:bg-slate-500/30",
-
icon_text: "text-slate-600 dark:text-slate-300",
-
badge_bg: "bg-slate-200 dark:bg-slate-600/30",
-
badge_text: "text-slate-700 dark:text-slate-300",
-
dot: "bg-slate-400 dark:bg-slate-400"
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# Helper module for Pagy pagination
-
#
-
# Provides access to Pagy frontend helpers in views
-
1
module PagyHelper
-
1
include Pagy::Frontend
-
end
-
-
# frozen_string_literal: true
-
-
1
require "cgi"
-
-
# Helper for formatting extracted text content (descriptions, requirements, etc.)
-
#
-
# Provides smart formatting that:
-
# - Detects and renders bullet points as lists
-
# - Detects and renders numbered lists
-
# - Detects implicit lists (lines that look like list items)
-
# - Detects and formats hash/array data structures
-
# - Preserves paragraphs
-
# - Supports basic markdown rendering
-
# - Sanitizes HTML for security
-
#
-
# @example
-
# <%= format_job_text(@job_listing.description) %>
-
1
module TextFormatterHelper
-
# Patterns to detect Ruby hash/array syntax
-
1
HASH_PATTERN = /\A\s*\{.*=>\s*.*\}\s*\z/m
-
1
ARRAY_PATTERN = /\A\s*\[.*\]\s*\z/m
-
1
HASH_ARROW_PATTERN = /["\']([^"\']+)["\']\s*=>\s*/
-
# Patterns that indicate a line is likely a list item even without bullet markers
-
1
IMPLICIT_LIST_PATTERNS = [
-
/^You'll\s/i, # "You'll be responsible for..."
-
/^You\s+will\s/i, # "You will design..."
-
/^We're\s+looking/i, # "We're looking for..."
-
/^Must\s+have/i, # "Must have experience..."
-
/^Should\s+have/i, # "Should have knowledge..."
-
/^Experience\s+(with|in)/i, # "Experience with..."
-
/^Strong\s/i, # "Strong communication skills"
-
/^Excellent\s/i, # "Excellent problem solving"
-
/^Ability\s+to/i, # "Ability to work..."
-
/^Knowledge\s+of/i, # "Knowledge of..."
-
/^Familiarity\s+with/i, # "Familiarity with..."
-
/^Proficiency\s+in/i, # "Proficiency in..."
-
/^Understanding\s+of/i, # "Understanding of..."
-
/^Proven\s/i, # "Proven track record..."
-
/^Deep\s+expertise/i, # "Deep expertise in..."
-
/^\d+\+?\s*years?/i, # "5+ years experience"
-
/^Bachelor'?s?\s/i, # "Bachelor's degree"
-
/^Master'?s?\s/i, # "Master's degree"
-
/^PhD\s/i, # "PhD in..."
-
/^Build\s/i, # "Build scalable systems"
-
/^Design\s/i, # "Design and implement..."
-
/^Develop\s/i, # "Develop new features"
-
/^Lead\s/i, # "Lead a team of..."
-
/^Manage\s/i, # "Manage projects..."
-
/^Work\s+(with|closely)/i, # "Work with cross-functional teams"
-
/^Collaborate\s/i, # "Collaborate with..."
-
/^Own\s/i, # "Own the entire..."
-
/^Drive\s/i # "Drive technical decisions"
-
].freeze
-
-
# Formats job listing text with smart detection and markdown support
-
#
-
# @param [String] text The raw text to format
-
# @param [Hash] options Formatting options
-
# @option options [Boolean] :markdown Enable markdown parsing (default: true)
-
# @option options [Boolean] :detect_lists Auto-detect bullet/numbered lists (default: true)
-
# @option options [Boolean] :linkify Convert URLs to links (default: true)
-
# @return [String] Formatted HTML
-
1
def format_job_text(text, options = {})
-
then: 0
else: 0
return "" if text.blank?
-
-
options = {
-
markdown: true,
-
detect_lists: true,
-
detect_implicit_lists: true,
-
linkify: true
-
}.merge(options)
-
-
formatted = text.to_s.dup
-
-
# Some sources (notably Greenhouse boards API) return HTML as escaped entities
-
# (e.g., "<p>...</p>"). Decode first so we can sanitize+render properly.
-
then: 0
else: 0
if formatted.include?("<") && formatted.include?(">")
-
begin
-
formatted = CGI.unescapeHTML(formatted)
-
rescue
-
nil
-
end
-
end
-
-
# If this is already HTML (and not markdown-like), keep structure and just sanitize.
-
# Note: some markdown content may contain inline HTML (e.g. <strong> inside list items).
-
then: 0
else: 0
if formatted.match?(%r{</?(p|ul|ol|li|h2|h3|h4|div|span|strong|em|br)\b}i) && !looks_like_markdown?(formatted)
-
return sanitize(formatted, tags: allowed_tags, attributes: allowed_attributes)
-
end
-
-
# First, check if this looks like a Ruby hash or array
-
then: 0
if looks_like_ruby_data?(formatted)
-
formatted = format_ruby_data(formatted)
-
else: 0
# Next, try to detect if it looks like markdown
-
then: 0
elsif options[:markdown] && looks_like_markdown?(formatted)
-
formatted = render_markdown(formatted)
-
else
-
else: 0
# Convert detected lists to HTML (including implicit lists)
-
then: 0
else: 0
if options[:detect_lists]
-
formatted = convert_lists_to_html(formatted, detect_implicit: options[:detect_implicit_lists])
-
end
-
formatted = convert_paragraphs_to_html(formatted)
-
end
-
-
# Convert URLs to links
-
then: 0
else: 0
if options[:linkify]
-
formatted = linkify_urls(formatted)
-
end
-
-
# Sanitize and return
-
sanitize(formatted, tags: allowed_tags, attributes: allowed_attributes)
-
end
-
-
# Formats text specifically for requirements/responsibilities lists
-
# More aggressive about detecting list items
-
#
-
# @param [String] text The raw text
-
# @return [String] Formatted HTML
-
1
def format_list_text(text)
-
then: 0
else: 0
return "" if text.blank?
-
-
formatted = text.to_s.dup
-
-
# Always try to detect lists for requirements/responsibilities
-
then: 0
if looks_like_markdown?(formatted)
-
formatted = render_markdown(formatted)
-
else
-
else: 0
# Use aggressive implicit list detection for requirements/responsibilities
-
formatted = convert_lists_to_html(formatted, detect_implicit: true, force_list: true)
-
formatted = convert_paragraphs_to_html(formatted)
-
end
-
-
sanitize(formatted, tags: allowed_tags, attributes: allowed_attributes)
-
end
-
-
# Formats key-value pairs for display (e.g., "Contract Type: B2B")
-
#
-
# @param [String] text Text containing key-value pairs
-
# @return [String] Formatted HTML with styled key-value display
-
1
def format_key_value_text(text)
-
then: 0
else: 0
return "" if text.blank?
-
-
lines = text.to_s.strip.split("\n").map(&:strip).reject(&:blank?)
-
-
# Check if this looks like key-value data
-
kv_pairs = lines.map do |line|
-
then: 0
if line.match?(/^([^:]+):\s*(.+)$/)
-
match = line.match(/^([^:]+):\s*(.+)$/)
-
{ key: match[1].strip, value: match[2].strip }
-
else: 0
else
-
{ text: line }
-
end
-
end
-
-
# If most lines are key-value pairs, render as definition list
-
kv_count = kv_pairs.count { |p| p[:key] }
-
if kv_count >= (lines.count * 0.5) && kv_count >= 2
-
then: 0
# Sanitize the rendered key-value list HTML
-
sanitize(render_key_value_list(kv_pairs), tags: allowed_tags, attributes: allowed_attributes)
-
else
-
else: 0
# Fall back to regular list formatting
-
format_list_text(text)
-
end
-
end
-
-
# Checks if text appears to contain markdown formatting
-
#
-
# @param [String] text The text to check
-
# @return [Boolean] True if markdown-like
-
1
def looks_like_markdown?(text)
-
then: 0
else: 0
return false if text.blank?
-
-
# Check for common markdown patterns
-
markdown_patterns = [
-
/^#+\s/m, # Headers
-
/^\s*[-*+]\s+/m, # Unordered lists
-
/^\s*\d+\.\s+/m, # Ordered lists
-
/\*\*[^*]+\*\*/, # Bold
-
/\*[^*]+\*/, # Italic
-
/`[^`]+`/, # Code
-
/\[[^\]]+\]\([^)]+\)/ # Links
-
]
-
-
markdown_patterns.any? { |pattern| text.match?(pattern) }
-
end
-
-
# Checks if text looks like Ruby hash or array syntax
-
#
-
# @param [String] text The text to check
-
# @return [Boolean] True if it looks like Ruby data
-
1
def looks_like_ruby_data?(text)
-
then: 0
else: 0
return false if text.blank?
-
-
stripped = text.to_s.strip
-
-
# Check for hash arrow syntax {"key" => "value"} or symbol keys {:key => "value"}
-
then: 0
else: 0
return true if stripped.match?(HASH_PATTERN) && stripped.include?("=>")
-
-
# Check for array of hashes [{...}, {...}]
-
then: 0
else: 0
return true if stripped.match?(ARRAY_PATTERN) && stripped.include?("=>")
-
-
false
-
end
-
-
# Formats Ruby hash/array data into readable HTML
-
#
-
# @param [String] text The Ruby data string
-
# @return [String] Formatted HTML
-
1
def format_ruby_data(text)
-
stripped = text.to_s.strip
-
-
begin
-
# Try to safely parse the Ruby-like data
-
parsed = parse_ruby_data(stripped)
-
render_parsed_data(parsed)
-
rescue StandardError => e
-
Rails.logger.warn("Failed to parse Ruby data: #{e.message}")
-
# Fall back to basic formatting if parsing fails
-
format_ruby_data_fallback(stripped)
-
end
-
end
-
-
# Parses Ruby-like hash/array syntax
-
#
-
# @param [String] text The text to parse
-
# @return [Hash, Array, String] Parsed data
-
1
def parse_ruby_data(text)
-
# Replace Ruby hash rockets with JSON-like syntax for parsing
-
# Convert "key" => "value" to "key": "value"
-
json_like = text.dup
-
-
# Handle symbol keys like :key => to "key":
-
json_like.gsub!(/:(\w+)\s*=>/, '"\1":')
-
-
# Handle string keys like "key" => to "key":
-
json_like.gsub!(/["']([^"']+)["']\s*=>/, '"\1":')
-
-
# Try to parse as JSON
-
JSON.parse(json_like)
-
rescue JSON::ParserError
-
# If JSON parsing fails, return the original text
-
text
-
end
-
-
# Renders parsed data into HTML
-
#
-
# @param [Object] data The parsed data
-
# @param [Integer] depth Current nesting depth
-
# @return [String] HTML output
-
1
def render_parsed_data(data, depth = 0)
-
case data
-
when: 0
when Hash
-
render_hash_data(data, depth)
-
when: 0
when Array
-
then: 0
if data.first.is_a?(Hash)
-
render_array_of_hashes(data, depth)
-
else: 0
else
-
render_simple_array(data, depth)
-
end
-
else: 0
else
-
"<p class=\"text-gray-600 dark:text-gray-400\">#{ERB::Util.html_escape(data.to_s)}</p>"
-
end
-
end
-
-
# Renders a hash as a definition list
-
#
-
# @param [Hash] hash The hash to render
-
# @param [Integer] depth Current depth
-
# @return [String] HTML
-
1
def render_hash_data(hash, depth = 0)
-
items = hash.map do |key, value|
-
formatted_key = humanize_key(key.to_s)
-
formatted_value = format_hash_value(value, depth)
-
-
<<~HTML
-
<div class="flex flex-col sm:flex-row sm:gap-2 py-1.5">
-
<dt class="text-sm font-medium text-gray-700 dark:text-gray-300 sm:min-w-[140px]">#{ERB::Util.html_escape(formatted_key)}</dt>
-
<dd class="text-sm text-gray-600 dark:text-gray-400">#{formatted_value}</dd>
-
</div>
-
HTML
-
end.join
-
-
"<dl class=\"divide-y divide-gray-100 dark:divide-gray-700\">#{items}</dl>"
-
end
-
-
# Formats a hash value for display
-
#
-
# @param [Object] value The value to format
-
# @param [Integer] depth Current depth
-
# @return [String] Formatted HTML
-
1
def format_hash_value(value, depth)
-
case value
-
when: 0
when Array
-
if value.all? { |v| v.is_a?(String) || v.is_a?(Numeric) }
-
then: 0
# Simple array of values - render as comma-separated or pills
-
pills = value.map do |v|
-
"<span class=\"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 mr-1 mb-1\">#{ERB::Util.html_escape(v)}</span>"
-
end.join
-
"<div class=\"flex flex-wrap\">#{pills}</div>"
-
else: 0
else
-
render_parsed_data(value, depth + 1)
-
end
-
when: 0
when Hash
-
render_parsed_data(value, depth + 1)
-
else: 0
else
-
ERB::Util.html_escape(value.to_s)
-
end
-
end
-
-
# Renders an array of hashes (like recruitment process steps)
-
#
-
# @param [Array<Hash>] array The array of hashes
-
# @param [Integer] depth Current depth
-
# @return [String] HTML
-
1
def render_array_of_hashes(array, depth = 0)
-
items = array.map.with_index do |item, index|
-
# Try to find a name/title/step key for the header
-
header = item["name"] || item["title"] || item["step"] || "Item #{index + 1}"
-
then: 0
else: 0
header = "Step #{item['step']}: #{item['name']}" if item["step"] && item["name"]
-
-
details = item.except("name", "title", "step").map do |key, value|
-
formatted_key = humanize_key(key.to_s)
-
then: 0
else: 0
formatted_value = value.is_a?(Array) ? value.join(", ") : value.to_s
-
"<span class=\"text-gray-500 dark:text-gray-400\">#{ERB::Util.html_escape(formatted_key)}:</span> #{ERB::Util.html_escape(formatted_value)}"
-
end.join(" • ")
-
-
<<~HTML
-
<li class="py-2">
-
<div class="font-medium text-gray-800 dark:text-gray-200">#{ERB::Util.html_escape(header)}</div>
-
<div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5">#{details}</div>
-
</li>
-
HTML
-
end.join
-
-
"<ol class=\"divide-y divide-gray-100 dark:divide-gray-700 list-none\">#{items}</ol>"
-
end
-
-
# Renders a simple array as a list
-
#
-
# @param [Array] array The array to render
-
# @param [Integer] depth Current depth
-
# @return [String] HTML
-
1
def render_simple_array(array, depth = 0)
-
items = array.map do |item|
-
"<li class=\"py-1\">#{ERB::Util.html_escape(item.to_s)}</li>"
-
end.join
-
-
"<ul class=\"list-disc list-inside space-y-1 text-gray-600 dark:text-gray-400\">#{items}</ul>"
-
end
-
-
# Humanizes a snake_case or camelCase key
-
#
-
# @param [String] key The key to humanize
-
# @return [String] Humanized key
-
1
def humanize_key(key)
-
key.to_s
-
.gsub(/([a-z])([A-Z])/, '\1 \2') # Split camelCase
-
.gsub("_", " ") # Split snake_case
-
.titleize
-
end
-
-
# Fallback formatting for Ruby data that couldn't be parsed
-
#
-
# @param [String] text The original text
-
# @return [String] Formatted HTML
-
1
def format_ruby_data_fallback(text)
-
# Try to extract key-value pairs from the string representation
-
pairs = []
-
text.scan(/["']([^"']+)["']\s*=>\s*["']?([^"',}\]]+)["']?/) do |key, value|
-
pairs << { key: humanize_key(key), value: value.strip }
-
end
-
-
then: 0
if pairs.any?
-
items = pairs.map do |pair|
-
<<~HTML
-
<div class="flex flex-col sm:flex-row sm:gap-2 py-1">
-
<dt class="text-sm font-medium text-gray-700 dark:text-gray-300 sm:min-w-[140px]">#{ERB::Util.html_escape(pair[:key])}</dt>
-
<dd class="text-sm text-gray-600 dark:text-gray-400">#{ERB::Util.html_escape(pair[:value])}</dd>
-
</div>
-
HTML
-
end.join
-
-
"<dl class=\"divide-y divide-gray-100 dark:divide-gray-700\">#{items}</dl>"
-
else
-
else: 0
# Just show the raw text with better formatting
-
"<p class=\"text-gray-600 dark:text-gray-400 font-mono text-sm\">#{ERB::Util.html_escape(text)}</p>"
-
end
-
end
-
-
1
private
-
-
# Renders key-value pairs as a styled definition list
-
#
-
# @param [Array<Hash>] pairs Array of key-value pairs
-
# @return [String] HTML
-
1
def render_key_value_list(pairs)
-
items = pairs.map do |pair|
-
then: 0
if pair[:key]
-
<<~HTML
-
<div class="flex flex-col sm:flex-row sm:gap-2 py-1">
-
<dt class="text-sm font-medium text-gray-700 dark:text-gray-300 sm:min-w-[140px]">#{ERB::Util.html_escape(pair[:key])}</dt>
-
<dd class="text-sm text-gray-600 dark:text-gray-400">#{ERB::Util.html_escape(pair[:value])}</dd>
-
</div>
-
HTML
-
else: 0
else
-
"<p class=\"text-sm text-gray-600 dark:text-gray-400 py-1\">#{ERB::Util.html_escape(pair[:text])}</p>"
-
end
-
end.join
-
-
"<dl class=\"divide-y divide-gray-100 dark:divide-gray-700\">#{items}</dl>"
-
end
-
-
# Renders markdown to HTML using a simple parser
-
#
-
# Outputs plain HTML elements without inline classes - styling is handled
-
# by the parent container's CSS class (e.g., job-prose, doc-content).
-
#
-
# @param [String] text The markdown text
-
# @return [String] HTML output
-
1
def render_markdown(text)
-
html = text.dup
-
-
# Horizontal rules (---, ***, ___) - use [^\n]* to avoid crossing lines
-
html.gsub!(/^[^\S\n]*(---|\*\*\*|___)[^\S\n]*$/, "<hr>")
-
-
# Blockquotes - use [^\n]+ to match only within a single line
-
html.gsub!(/^[^\S\n]*>[^\S\n]*([^\n]+)$/, '<blockquote>\1</blockquote>')
-
-
# Headers - use [^\n]+ to prevent matching across lines
-
html.gsub!(/^####[^\S\n]+([^\n]+)$/, '<h5>\1</h5>')
-
html.gsub!(/^###[^\S\n]+([^\n]+)$/, '<h4>\1</h4>')
-
html.gsub!(/^##[^\S\n]+([^\n]+)$/, '<h3>\1</h3>')
-
html.gsub!(/^#[^\S\n]+([^\n]+)$/, '<h2>\1</h2>')
-
-
# Bold and italic (non-greedy matching)
-
html.gsub!(/\*\*([^*\n]+?)\*\*/, '<strong>\1</strong>')
-
html.gsub!(/\*([^*\n]+?)\*/, '<em>\1</em>')
-
-
# Inline code
-
html.gsub!(/`([^`\n]+?)`/, '<code>\1</code>')
-
-
# Convert lists and paragraphs
-
html = convert_lists_to_html(html, detect_implicit: true)
-
html = convert_paragraphs_to_html(html)
-
-
html
-
end
-
-
# Converts detected bullet and numbered lists to HTML
-
# Also detects implicit lists (lines that look like list items)
-
#
-
# @param [String] text The text
-
# @param [Boolean] detect_implicit Whether to detect implicit list items
-
# @param [Boolean] force_list Whether to force list rendering for newline-separated items
-
# @return [String] Text with HTML lists
-
1
def convert_lists_to_html(text, detect_implicit: false, force_list: false)
-
lines = text.split("\n")
-
result = []
-
current_list_type = nil
-
list_items = []
-
-
# Check if we should force list mode (multiple short lines that look like items)
-
then: 0
else: 0
if force_list
-
non_empty_lines = lines.map(&:strip).reject(&:blank?)
-
if non_empty_lines.length >= 3 && non_empty_lines.all? { |l| l.length < 200 }
-
then: 0
# Check if most lines look like list items
-
implicit_count = non_empty_lines.count { |l| looks_like_implicit_list_item?(l) }
-
force_list = implicit_count >= (non_empty_lines.length * 0.5)
-
else: 0
else
-
force_list = false
-
end
-
end
-
-
lines.each do |line|
-
stripped = line.strip
-
-
# Detect explicit bullet points (-, *, •, ►, ▪)
-
then: 0
if stripped.match?(/^[-*•►▪]\s+/)
-
then: 0
else: 0
if current_list_type != :ul
-
then: 0
else: 0
result << close_list(current_list_type, list_items) if current_list_type
-
current_list_type = :ul
-
list_items = []
-
end
-
list_items << stripped.sub(/^[-*•►▪]\s+/, "")
-
-
else: 0
# Detect numbered lists (1., 2., a., b., etc.)
-
then: 0
elsif stripped.match?(/^(\d+|[a-zA-Z])[.)]\s+/)
-
then: 0
else: 0
if current_list_type != :ol
-
then: 0
else: 0
result << close_list(current_list_type, list_items) if current_list_type
-
current_list_type = :ol
-
list_items = []
-
end
-
list_items << stripped.sub(/^(\d+|[a-zA-Z])[.)]\s+/, "")
-
-
else: 0
# Detect implicit list items (if enabled)
-
then: 0
elsif (detect_implicit || force_list) && stripped.present? && looks_like_implicit_list_item?(stripped)
-
then: 0
else: 0
if current_list_type != :ul_implicit
-
then: 0
else: 0
result << close_list(current_list_type, list_items) if current_list_type
-
current_list_type = :ul_implicit
-
list_items = []
-
end
-
list_items << stripped
-
-
else
-
else: 0
# Close any open list
-
then: 0
else: 0
if current_list_type
-
result << close_list(current_list_type, list_items)
-
current_list_type = nil
-
list_items = []
-
end
-
result << line
-
end
-
end
-
-
# Close final list if any
-
then: 0
else: 0
result << close_list(current_list_type, list_items) if current_list_type
-
-
result.join("\n")
-
end
-
-
# Checks if a line looks like an implicit list item
-
#
-
# @param [String] line The line to check
-
# @return [Boolean] True if it looks like a list item
-
1
def looks_like_implicit_list_item?(line)
-
then: 0
else: 0
return false if line.blank?
-
then: 0
else: 0
return false if line.length > 300 # Too long to be a list item
-
-
IMPLICIT_LIST_PATTERNS.any? { |pattern| line.match?(pattern) }
-
end
-
-
# Closes a list and returns HTML
-
#
-
# Outputs plain HTML elements - styling is handled by the parent container's
-
# CSS class (e.g., job-prose, doc-content).
-
#
-
# @param [Symbol] list_type :ul, :ol, or :ul_implicit
-
# @param [Array<String>] items List items
-
# @return [String] HTML list
-
1
def close_list(list_type, items)
-
then: 0
else: 0
return "" if items.empty?
-
-
then: 0
else: 0
tag = list_type == :ol ? "ol" : "ul"
-
-
items_html = items.map do |item|
-
# Keep inline HTML (e.g. <strong>) - sanitize happens in format_job_text
-
"<li>#{item}</li>"
-
end.join("\n")
-
-
"<#{tag}>#{items_html}</#{tag}>"
-
end
-
-
# Converts text paragraphs to HTML <p> tags
-
#
-
# Outputs plain <p> elements - styling is handled by the parent container's CSS.
-
#
-
# @param [String] text The text
-
# @return [String] Text with HTML paragraphs
-
1
def convert_paragraphs_to_html(text)
-
# Split by double newlines (or single newlines followed by blank lines)
-
paragraphs = text.split(/\n\n+/)
-
-
paragraphs.map do |para|
-
para = para.strip
-
then: 0
else: 0
next "" if para.blank?
-
-
# Don't wrap if it's already a block element
-
then: 0
if para.start_with?("<h", "<ul", "<ol", "<div", "<p", "<dl", "<blockquote", "<hr")
-
para
-
else
-
else: 0
# Replace single newlines with <br> within paragraphs
-
content = para.gsub(/\n/, "<br>")
-
"<p>#{content}</p>"
-
end
-
end.join("\n")
-
end
-
-
# Converts URLs in text to clickable links
-
#
-
# @param [String] text The text
-
# @return [String] Text with linked URLs
-
1
def linkify_urls(text)
-
url_pattern = %r{(https?://[^\s<>"]+)}
-
-
text.gsub(url_pattern) do |url|
-
"<a href=\"#{ERB::Util.html_escape(url)}\" target=\"_blank\" rel=\"noopener\">#{ERB::Util.html_escape(url)}</a>"
-
end
-
end
-
-
# Returns allowed HTML tags for sanitization
-
#
-
# @return [Array<String>] Allowed tags
-
1
def allowed_tags
-
%w[h2 h3 h4 h5 p br ul ol li strong em b i code a span div dl dt dd blockquote hr]
-
end
-
-
# Returns allowed HTML attributes for sanitization
-
#
-
# @return [Array<String>] Allowed attributes
-
1
def allowed_attributes
-
%w[class href target rel]
-
end
-
end
-
# frozen_string_literal: true
-
-
# Helper for Cloudflare Turnstile integration
-
1
module TurnstileHelper
-
# Returns the Turnstile site key
-
#
-
# @return [String, nil]
-
1
def turnstile_site_key
-
CloudflareTurnstileService.site_key
-
end
-
-
# Checks if Turnstile is configured and enabled
-
# Only returns true if BOTH site key and secret key are present
-
# and the setting is enabled. This ensures the widget is only shown
-
# when verification can actually succeed.
-
#
-
# @return [Boolean]
-
1
def turnstile_configured?
-
Setting.turnstile_enabled? && CloudflareTurnstileService.fully_configured?
-
end
-
end
-
# frozen_string_literal: true
-
-
# Background job for analyzing uploaded resumes with AI skill extraction
-
#
-
# Runs the complete analysis pipeline: text extraction -> AI analysis -> skill creation
-
#
-
# @example
-
# AnalyzeResumeJob.perform_later(user_resume)
-
#
-
class AnalyzeResumeJob < ApplicationJob
-
queue_as :default
-
-
# Retry on transient failures (API timeouts, rate limits, etc.)
-
retry_on StandardError, wait: :polynomially_longer, attempts: 3
-
-
# Don't retry on permanent failures
-
discard_on ActiveRecord::RecordNotFound
-
-
# Analyze the resume and extract skills
-
#
-
# @param user_resume [UserResume] The resume to analyze
-
# @return [void]
-
def perform(user_resume)
-
@user_resume = user_resume
-
-
# Skip if already analyzed
-
if user_resume.analyzed?
-
Rails.logger.info("Resume #{user_resume.id} already analyzed, skipping")
-
return
-
end
-
-
# Skip if currently processing (prevent duplicate jobs)
-
if user_resume.analyzing?
-
Rails.logger.info("Resume #{user_resume.id} already processing, skipping")
-
return
-
end
-
-
Rails.logger.info("Starting analysis for resume #{user_resume.id}: #{user_resume.name}")
-
-
result = Resumes::AnalysisService.new(user_resume).run
-
-
if result[:success]
-
Rails.logger.info(
-
"Successfully analyzed resume #{user_resume.id}: " \
-
"#{result[:skills_count]} skills extracted using #{result[:provider]}"
-
)
-
-
# Recompute fit scores since the user's skill profile may have changed.
-
RecomputeFitAssessmentsForUserJob.perform_later(user_resume.user_id)
-
else
-
Rails.logger.error("Failed to analyze resume #{user_resume.id}: #{result[:error]}")
-
notify_error(
-
RuntimeError.new(result[:error]),
-
context: "resume_analysis",
-
severity: "warning",
-
user: user_resume.user,
-
user_resume_id: user_resume.id
-
)
-
end
-
rescue StandardError => e
-
handle_error(e,
-
context: "resume_analysis",
-
user: @user_resume&.user,
-
user_resume_id: @user_resume&.id
-
)
-
end
-
end
-
class ApplicationJob < ActiveJob::Base
-
# Automatically retry jobs that encountered a deadlock
-
# retry_on ActiveRecord::Deadlocked
-
-
# Most jobs are safe to ignore if the underlying records are no longer available
-
# discard_on ActiveJob::DeserializationError
-
-
protected
-
-
# Notifies of a general error with context
-
#
-
# @param exception [Exception] The exception to report
-
# @param context [String] Error context (e.g., 'payment', 'sync')
-
# @param severity [String] Severity level ('error', 'warning', 'info')
-
# @param user [User, nil] User associated with the error
-
# @param extra [Hash] Additional context
-
# @return [void]
-
def notify_error(exception, context:, severity: "error", user: nil, **extra)
-
user_info = case user
-
when User
-
{ id: user.id, email: user.email_address }
-
when Hash
-
user
-
end
-
-
ExceptionNotifier.notify(exception, {
-
context: context,
-
severity: severity,
-
user: user_info
-
}.merge(extra).compact)
-
end
-
-
# Notifies of an AI-related error with AI-specific context
-
#
-
# @param exception [Exception] The exception to report
-
# @param operation [String, Symbol] AI operation type
-
# @param provider [String, nil] LLM provider name
-
# @param model [String, nil] Model identifier
-
# @param loggable [ApplicationRecord, nil] The record being processed
-
# @param severity [String] Severity level
-
# @param extra [Hash] Additional context
-
# @return [void]
-
def notify_ai_error(exception, operation:, provider: nil, model: nil, loggable: nil, severity: "error", **extra)
-
ai_context = {
-
operation: operation.to_s,
-
provider_name: provider,
-
model_identifier: model,
-
analyzable_type: loggable&.class&.name,
-
analyzable_id: loggable&.id,
-
severity: severity
-
}.merge(extra.to_h).compact
-
-
ExceptionNotifier.notify_ai_error(exception, ai_context)
-
end
-
-
# Logs an error and notifies, then optionally re-raises
-
#
-
# @param exception [Exception] The exception
-
# @param context [String] Error context
-
# @param user [User, nil] Associated user
-
# @param reraise [Boolean] Whether to re-raise the exception
-
# @param extra [Hash] Additional context
-
# @return [void]
-
def handle_error(exception, context:, user: nil, reraise: true, **extra)
-
Rails.logger.error("[#{self.class.name}] #{exception.message}")
-
notify_error(exception, context: context, user: user, **extra)
-
raise exception if reraise
-
end
-
end
-
# frozen_string_literal: true
-
-
# Processes an assistant chat message asynchronously.
-
# Called after user message is created, runs LLM and broadcasts result.
-
class AssistantChatJob < ApplicationJob
-
queue_as :default
-
-
# @param thread_id [Integer] the chat thread ID
-
# @param user_id [Integer] the user ID
-
# @param user_message_id [Integer] the user message ID
-
# @param trace_id [String] the trace ID for this turn
-
# @param client_request_uuid [String, nil] optional client request UUID for idempotency
-
def perform(thread_id:, user_id:, user_message_id:, trace_id:, client_request_uuid: nil)
-
thread = Assistant::ChatThread.find_by(id: thread_id)
-
user = User.find_by(id: user_id)
-
user_message = Assistant::ChatMessage.find_by(id: user_message_id)
-
-
return unless thread && user && user_message
-
-
begin
-
result = Assistant::Chat::TurnRunner.new(
-
user: user,
-
thread: thread,
-
user_message: user_message,
-
trace_id: trace_id,
-
client_request_uuid: client_request_uuid,
-
page_context: user_message.metadata["page_context"] || {}
-
).call
-
-
broadcast_assistant_message(thread, result[:assistant_message], trace_id)
-
# Always broadcast tool action items, even if this turn deduped all tool calls and didn't create
-
# new tool_executions. This keeps the UI consistent without requiring refresh.
-
broadcast_tool_executions(thread)
-
rescue StandardError => e
-
Rails.logger.error("[AssistantChatJob] Error processing message: #{e.message}")
-
Ai::ErrorReporter.notify(
-
e,
-
operation: :assistant_chat_job,
-
provider: nil,
-
model: nil,
-
user: user,
-
thread: thread,
-
trace_id: trace_id,
-
extra: { user_message_id: user_message.id }
-
)
-
broadcast_error_message(thread, trace_id, e.message)
-
end
-
end
-
-
private
-
-
def broadcast_assistant_message(thread, assistant_message, trace_id)
-
# Remove thinking indicator and append assistant message
-
Turbo::StreamsChannel.broadcast_remove_to(
-
"assistant_thread_#{thread.id}",
-
target: "thinking_#{trace_id}"
-
)
-
-
Turbo::StreamsChannel.broadcast_append_to(
-
"assistant_thread_#{thread.id}",
-
target: ActionView::RecordIdentifier.dom_id(thread, :messages),
-
partial: "assistant/threads/message",
-
locals: { message: assistant_message }
-
)
-
end
-
-
def broadcast_tool_executions(thread)
-
tool_executions = thread.tool_executions.order(created_at: :desc)
-
tool_action_items = tool_executions.select { |te| te.status.in?(%w[proposed queued running]) }
-
-
Turbo::StreamsChannel.broadcast_replace_to(
-
"assistant_thread_#{thread.id}",
-
target: ActionView::RecordIdentifier.dom_id(thread, :tool_executions),
-
partial: "assistant/threads/tool_proposals",
-
locals: { thread: thread, tool_executions: tool_action_items }
-
)
-
end
-
-
def broadcast_error_message(thread, trace_id, error_message)
-
# Remove thinking indicator
-
Turbo::StreamsChannel.broadcast_remove_to(
-
"assistant_thread_#{thread.id}",
-
target: "thinking_#{trace_id}"
-
)
-
-
# Append error message
-
Turbo::StreamsChannel.broadcast_append_to(
-
"assistant_thread_#{thread.id}",
-
target: ActionView::RecordIdentifier.dom_id(thread, :messages),
-
partial: "assistant/threads/error_message",
-
locals: { error_message: "Sorry, I encountered an issue processing your request. Please try again." }
-
)
-
end
-
end
-
# frozen_string_literal: true
-
-
class AssistantMemoryProposerJob < ApplicationJob
-
queue_as :default
-
-
def perform(user_id, thread_id, trace_id)
-
user = User.find_by(id: user_id)
-
thread = Assistant::ChatThread.find_by(id: thread_id)
-
return unless user && thread
-
-
Assistant::Memory::MemoryProposer.new(user: user, thread: thread, trace_id: trace_id).propose!
-
end
-
end
-
# frozen_string_literal: true
-
-
class AssistantThreadSummarizerJob < ApplicationJob
-
queue_as :default
-
-
def perform(thread_id)
-
thread = Assistant::ChatThread.find_by(id: thread_id)
-
return unless thread
-
-
Assistant::Memory::ThreadSummarizer.new(thread: thread).maybe_summarize!
-
end
-
end
-
# frozen_string_literal: true
-
-
class AssistantToolExecutionJob < ApplicationJob
-
queue_as :default
-
-
def perform(tool_execution_id, approved_by_id: nil)
-
tool_execution = Assistant::ToolExecution.find_by(id: tool_execution_id)
-
return unless tool_execution
-
-
thread = tool_execution.thread
-
user = thread.user
-
approved_by = approved_by_id.present? ? User.find_by(id: approved_by_id) : tool_execution.approved_by
-
-
tool_execution.with_lock do
-
return if tool_execution.status == "success"
-
return if tool_execution.status == "running"
-
-
if tool_execution.status == "proposed"
-
tool_execution.update!(status: "queued")
-
end
-
end
-
-
Assistant::Tools::Runner.new(user: user, tool_execution: tool_execution, approved_by: approved_by).call
-
-
# Persist canonical tool result message for reliable provider follow-ups (new turns going forward).
-
Assistant::Chat::ToolResultMessagePersister.new(tool_execution: tool_execution).call
-
-
broadcast_tool_executions(thread)
-
-
enqueue_followup_if_ready(tool_execution)
-
rescue StandardError => e
-
Ai::ErrorReporter.notify(
-
e,
-
operation: :assistant_tool_execution_job,
-
provider: tool_execution&.provider_name,
-
model: nil,
-
user: user,
-
thread: thread,
-
trace_id: tool_execution&.trace_id,
-
extra: { tool_execution_id: tool_execution_id }
-
)
-
raise
-
end
-
-
private
-
-
def broadcast_tool_executions(thread)
-
tool_executions = thread.tool_executions.order(created_at: :desc)
-
tool_action_items = tool_executions.select { |te| te.status.in?(%w[proposed queued running]) }
-
-
Turbo::StreamsChannel.broadcast_replace_to(
-
"assistant_thread_#{thread.id}",
-
target: ActionView::RecordIdentifier.dom_id(thread, :tool_executions),
-
partial: "assistant/threads/tool_proposals",
-
locals: { thread: thread, tool_executions: tool_action_items }
-
)
-
rescue StandardError => e
-
Ai::ErrorReporter.notify(
-
e,
-
operation: :assistant_tool_execution_broadcast,
-
provider: nil,
-
model: nil,
-
user: thread.user,
-
thread: thread,
-
trace_id: nil
-
)
-
end
-
-
def enqueue_followup_if_ready(tool_execution)
-
thread = tool_execution.thread
-
assistant_message_id = tool_execution.assistant_message_id
-
-
return unless tool_execution.status.in?(%w[success error])
-
-
pending = thread.tool_executions.where(assistant_message_id: assistant_message_id, status: %w[proposed queued running]).exists?
-
return if pending
-
-
AssistantToolFollowupJob.perform_later(assistant_message_id)
-
rescue StandardError
-
# best-effort only
-
end
-
end
-
# frozen_string_literal: true
-
-
class AssistantToolFollowupJob < ApplicationJob
-
queue_as :default
-
-
# @param assistant_message_id [Integer] The assistant message that originated the tool calls.
-
def perform(assistant_message_id)
-
assistant_message = Assistant::ChatMessage.find_by(id: assistant_message_id)
-
return unless assistant_message
-
-
thread = assistant_message.thread
-
user = thread.user
-
-
thread.with_lock do
-
# Only run follow-up for placeholder messages that are waiting on tool results.
-
pending_followup = assistant_message.metadata["pending_tool_followup"] == true || assistant_message.metadata[:pending_tool_followup] == true
-
return unless pending_followup
-
-
pending = thread.tool_executions.where(assistant_message_id: assistant_message.id, status: %w[proposed queued running]).exists?
-
return if pending
-
end
-
-
result = Assistant::Chat::Components::ToolFollowupResponder.new(
-
user: user,
-
thread: thread,
-
originating_assistant_message: assistant_message
-
).call
-
-
assistant_message.update!(
-
content: result[:answer].to_s,
-
metadata: assistant_message.metadata.merge(
-
pending_tool_followup: false,
-
tool_followup_completed_at: Time.current.iso8601
-
)
-
)
-
-
broadcast_assistant_message_replace(thread, assistant_message)
-
rescue StandardError => e
-
Ai::ErrorReporter.notify(
-
e,
-
operation: :assistant_tool_followup_job,
-
provider: (assistant_message&.metadata&.dig("provider") || assistant_message&.metadata&.dig(:provider)),
-
model: (assistant_message&.metadata&.dig("model") || assistant_message&.metadata&.dig(:model)),
-
user: user,
-
thread: thread,
-
trace_id: (assistant_message&.metadata&.dig("trace_id") || assistant_message&.metadata&.dig(:trace_id)),
-
extra: { assistant_message_id: assistant_message_id }
-
)
-
raise
-
end
-
-
private
-
-
def broadcast_assistant_message_replace(thread, assistant_message)
-
Turbo::StreamsChannel.broadcast_replace_to(
-
"assistant_thread_#{thread.id}",
-
target: ActionView::RecordIdentifier.dom_id(assistant_message),
-
partial: "assistant/threads/message",
-
locals: { message: assistant_message }
-
)
-
rescue StandardError
-
# best-effort only
-
end
-
end
-
# frozen_string_literal: true
-
-
module Billing
-
# Processes a stored billing webhook event asynchronously.
-
class ProcessWebhookEventJob < ApplicationJob
-
queue_as :default
-
-
retry_on StandardError, wait: :polynomially_longer, attempts: 3
-
-
# @param webhook_event [Billing::WebhookEvent]
-
def perform(webhook_event)
-
webhook_event = Billing::WebhookEvent.find(webhook_event.id) unless webhook_event.is_a?(Billing::WebhookEvent)
-
return unless webhook_event.status == "pending"
-
-
processor = Billing::Webhooks::Processor.new(webhook_event)
-
processor.run
-
rescue StandardError => e
-
handle_error(e,
-
context: "billing_webhook_processing",
-
webhook_event_id: webhook_event&.id,
-
provider: webhook_event&.provider,
-
event_type: webhook_event&.event_type
-
)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# Background job to detect and clean up stuck scraping attempts
-
#
-
# Scraping attempts can get stuck in intermediate states (fetching, extracting)
-
# if the process hangs or crashes without proper error handling.
-
#
-
# This job runs periodically to:
-
# 1. Find attempts stuck in intermediate states for too long
-
# 2. Mark them as failed with appropriate error messages
-
# 3. Optionally retry or notify for manual review
-
#
-
# @example Run manually
-
# CleanupStuckScrapingAttemptsJob.perform_now
-
#
-
# @example Schedule (add to config/recurring.yml for solid_queue)
-
# cleanup_stuck_scraping_attempts:
-
# class: CleanupStuckScrapingAttemptsJob
-
# schedule: every 10 minutes
-
class CleanupStuckScrapingAttemptsJob < ApplicationJob
-
queue_as :maintenance
-
-
# How long an attempt can be in an intermediate state before considered stuck
-
STUCK_THRESHOLD_MINUTES = 10
-
-
# Intermediate states that should transition quickly
-
INTERMEDIATE_STATES = %w[pending fetching extracting retrying].freeze
-
-
def perform
-
stuck_attempts = find_stuck_attempts
-
return if stuck_attempts.empty?
-
-
Rails.logger.info({
-
event: "cleanup_stuck_attempts_started",
-
count: stuck_attempts.count
-
}.to_json)
-
-
cleaned_count = 0
-
stuck_attempts.find_each do |attempt|
-
cleanup_attempt(attempt)
-
cleaned_count += 1
-
end
-
-
Rails.logger.info({
-
event: "cleanup_stuck_attempts_completed",
-
cleaned_count: cleaned_count
-
}.to_json)
-
end
-
-
private
-
-
def find_stuck_attempts
-
threshold = STUCK_THRESHOLD_MINUTES.minutes.ago
-
-
ScrapingAttempt
-
.where(status: INTERMEDIATE_STATES)
-
.where("updated_at < ?", threshold)
-
.order(updated_at: :asc)
-
end
-
-
def cleanup_attempt(attempt)
-
# Determine which step was stuck
-
last_event = attempt.scraping_events.order(created_at: :desc).first
-
stuck_step = last_event&.event_type || attempt.status
-
-
# Check if there's an incomplete event (started but not completed)
-
incomplete_event = attempt.scraping_events.find_by(status: :started)
-
if incomplete_event
-
incomplete_event.update!(
-
status: :failed,
-
completed_at: Time.current,
-
error_type: "StuckTimeout",
-
error_message: "Step timed out after #{STUCK_THRESHOLD_MINUTES} minutes"
-
)
-
end
-
-
# Mark the attempt as failed
-
attempt.update!(
-
status: :failed,
-
failed_step: stuck_step,
-
error_message: "Attempt stuck at '#{stuck_step}' for over #{STUCK_THRESHOLD_MINUTES} minutes - automatically cleaned up"
-
)
-
-
Rails.logger.warn({
-
event: "stuck_attempt_cleaned",
-
scraping_attempt_id: attempt.id,
-
job_listing_id: attempt.job_listing_id,
-
stuck_step: stuck_step,
-
stuck_since: attempt.updated_at.iso8601
-
}.to_json)
-
-
# Notify for monitoring
-
ExceptionNotifier.notify(
-
StandardError.new("Stuck scraping attempt cleaned up"),
-
{
-
context: "stuck_attempt_cleanup",
-
severity: "warning",
-
scraping_attempt_id: attempt.id,
-
job_listing_id: attempt.job_listing_id,
-
stuck_step: stuck_step,
-
stuck_duration_minutes: ((Time.current - attempt.updated_at) / 60).round
-
}
-
)
-
end
-
end
-
# frozen_string_literal: true
-
-
# Background job to compute a FitAssessment for a given (user, fittable).
-
class ComputeFitAssessmentJob < ApplicationJob
-
queue_as :default
-
-
discard_on ActiveRecord::RecordNotFound
-
-
# @param user_id [Integer]
-
# @param fittable_type [String]
-
# @param fittable_id [Integer]
-
def perform(user_id, fittable_type, fittable_id)
-
user = User.find(user_id)
-
fittable = fittable_type.constantize.find(fittable_id)
-
-
ComputeFitAssessmentService.new(user: user, fittable: fittable).call
-
end
-
end
-
# frozen_string_literal: true
-
-
# Generates and caches interview prep artifacts for an application.
-
#
-
# Enforces quota usage once per pack refresh (monthly window via Billing::UsageCounter).
-
class GenerateInterviewPrepPackJob < ApplicationJob
-
queue_as :default
-
-
# @param interview_application [InterviewApplication]
-
# @param user [User]
-
def perform(interview_application, user:)
-
ent = Billing::Entitlements.for(user)
-
return unless ent.allowed?(:interview_prepare_access)
-
-
remaining = ent.remaining(:interview_prepare_refreshes)
-
return if remaining.is_a?(Integer) && remaining <= 0
-
-
period = {
-
starts_at: Time.current.beginning_of_month,
-
ends_at: (Time.current.beginning_of_month + 1.month)
-
}
-
-
Billing::UsageCounter.increment!(
-
user: user,
-
feature_key: "interview_prepare_refreshes",
-
period_starts_at: period[:starts_at],
-
period_ends_at: period[:ends_at],
-
delta: 1
-
)
-
-
InterviewPrep::GenerateMatchAnalysisService.new(user: user, interview_application: interview_application).call
-
InterviewPrep::GenerateFocusAreasService.new(user: user, interview_application: interview_application).call
-
InterviewPrep::GenerateQuestionFramingService.new(user: user, interview_application: interview_application).call
-
InterviewPrep::GenerateStrengthPositioningService.new(user: user, interview_application: interview_application).call
-
rescue StandardError => e
-
handle_error(e,
-
context: "interview_prep_generation",
-
user: user,
-
interview_application_id: interview_application&.id,
-
reraise: false # Don't retry - each service handles its own failures
-
)
-
end
-
end
-
# frozen_string_literal: true
-
-
# Generates round-specific interview prep for an interview round.
-
#
-
# Uses InterviewRoundPrep::GenerateService to generate tailored preparation
-
# content based on round type, historical performance, and company patterns.
-
#
-
# Enforces quota usage per generation (monthly window via Billing::UsageCounter).
-
class GenerateRoundPrepJob < ApplicationJob
-
queue_as :default
-
-
# @param interview_round [InterviewRound]
-
# @param force [Boolean] Force regeneration even if prep exists
-
def perform(interview_round, force: false)
-
user = interview_round.interview_application.user
-
-
# Check entitlements
-
ent = Billing::Entitlements.for(user)
-
return unless ent.allowed?(:round_prep_access)
-
-
# Check quota
-
remaining = ent.remaining(:round_prep_generations)
-
return if remaining.is_a?(Integer) && remaining <= 0
-
-
# Track usage
-
period = {
-
starts_at: Time.current.beginning_of_month,
-
ends_at: (Time.current.beginning_of_month + 1.month)
-
}
-
-
Billing::UsageCounter.increment!(
-
user: user,
-
feature_key: "round_prep_generations",
-
period_starts_at: period[:starts_at],
-
period_ends_at: period[:ends_at],
-
delta: 1
-
)
-
-
# Generate the prep
-
InterviewRoundPrep::GenerateService.new(
-
interview_round: interview_round,
-
force: force
-
).call
-
rescue StandardError => e
-
# Mark artifact as failed
-
artifact = InterviewRoundPrepArtifact.find_by(
-
interview_round: interview_round,
-
kind: :comprehensive
-
)
-
artifact&.fail!(e.message)
-
-
handle_error(e,
-
context: "round_prep_generation",
-
user: user,
-
interview_round_id: interview_round.id
-
)
-
end
-
end
-
# frozen_string_literal: true
-
-
# Background job for syncing Gmail emails for all users with connected accounts
-
# This job is scheduled to run periodically via Solid Queue recurring jobs
-
class GmailSyncAllUsersJob < ApplicationJob
-
queue_as :default
-
-
# Performs Gmail sync for all users with connected Google accounts
-
# Only syncs accounts that have sync enabled and don't need reauthorization
-
# Accounts with expired access tokens will still be processed (refresh will be attempted)
-
def perform
-
accounts_to_sync = ConnectedAccount.google.sync_enabled.ready_for_sync
-
-
Rails.logger.info "Starting Gmail sync for #{accounts_to_sync.count} accounts"
-
-
accounts_to_sync.find_each do |account|
-
# Queue individual sync job for each account
-
# This allows individual syncs to fail without affecting others
-
GmailSyncJob.perform_later(account.user, connected_account: account)
-
rescue StandardError => e
-
Rails.logger.error "Failed to queue sync for account #{account.id}: #{e.message}"
-
end
-
-
Rails.logger.info "Gmail sync jobs queued for #{accounts_to_sync.count} accounts"
-
end
-
end
-
# frozen_string_literal: true
-
-
# Background job for syncing Gmail emails
-
# This job fetches interview-related emails from the user's Gmail account
-
class GmailSyncJob < ApplicationJob
-
queue_as :default
-
-
# Number of times to retry on transient failures
-
retry_on Gmail::Errors::TokenExpiredError, wait: :polynomially_longer, attempts: 3
-
retry_on Google::Apis::TransmissionError, wait: :polynomially_longer, attempts: 3
-
-
# Don't retry on auth errors - user needs to reconnect
-
# Use a block to mark account as needing reauth when discarded
-
discard_on Google::Apis::AuthorizationError do |job, error|
-
handle_auth_failure(job, error)
-
end
-
-
# Performs the Gmail sync for a user
-
#
-
# @param user [User] The user to sync emails for
-
# @param connected_account [ConnectedAccount, nil] Specific account to sync (optional)
-
def perform(user, connected_account: nil)
-
account = connected_account || user.google_account
-
-
unless account
-
Rails.logger.info "No Google account connected for user #{user.id}"
-
return
-
end
-
-
unless account.sync_enabled?
-
Rails.logger.info "Gmail sync disabled for user #{user.id}"
-
return
-
end
-
-
# Store account for potential error handling
-
@account = account
-
-
service = Gmail::SyncService.new(account)
-
result = service.run
-
-
if result[:success]
-
Rails.logger.info "Gmail sync completed for user #{user.id}: #{result[:emails_found]} emails found"
-
else
-
Rails.logger.warn "Gmail sync failed for user #{user.id}: #{result[:error]}"
-
-
# If reauth is needed, mark account and notify user
-
if result[:needs_reauth]
-
mark_needs_reauth_and_notify(account, result[:error])
-
end
-
end
-
-
result
-
end
-
-
private
-
-
# Marks the account as needing reauthorization and sends notification
-
#
-
# @param account [ConnectedAccount] The connected account
-
# @param error_message [String] The error message
-
def mark_needs_reauth_and_notify(account, error_message)
-
account.mark_needs_reauth!(error_message)
-
ConnectedAccountMailer.reauth_required(account).deliver_later
-
Rails.logger.info "Marked account #{account.id} as needing reauth and sent notification"
-
end
-
-
# Class method to handle auth failures from discard_on callback
-
#
-
# @param job [GmailSyncJob] The job instance
-
# @param error [Exception] The error that caused the discard
-
def self.handle_auth_failure(job, error)
-
# Extract the account from job arguments
-
user = job.arguments.first
-
account = job.arguments.second&.fetch(:connected_account, nil) || user&.google_account
-
-
return unless account
-
-
Rails.logger.error "Gmail authorization failed for account #{account.id}: #{error.message}"
-
account.mark_needs_reauth!(error.message)
-
ConnectedAccountMailer.reauth_required(account).deliver_later
-
end
-
end
-
# frozen_string_literal: true
-
-
# Background job for processing opportunity emails with AI extraction
-
#
-
# Runs AI extraction on opportunities to extract job details from recruiter emails.
-
# Called after a recruiter outreach email is detected and an opportunity is created.
-
#
-
# @example
-
# ProcessOpportunityEmailJob.perform_later(opportunity.id)
-
#
-
class ProcessOpportunityEmailJob < ApplicationJob
-
queue_as :default
-
-
# Retry on transient failures
-
retry_on StandardError, wait: :polynomially_longer, attempts: 3
-
-
# Don't retry on permanent failures
-
discard_on ActiveRecord::RecordNotFound
-
-
# Process the opportunity with AI extraction
-
#
-
# @param opportunity_id [Integer] The opportunity ID to process
-
# @return [void]
-
def perform(opportunity_id)
-
opportunity = Opportunity.find(opportunity_id)
-
-
# Skip if already processed (has extracted data)
-
return if opportunity.extracted_data["extracted_at"].present?
-
-
# Skip if no email attached
-
return unless opportunity.synced_email.present?
-
-
Rails.logger.info("Processing opportunity #{opportunity_id} for AI extraction")
-
-
# Run AI extraction
-
service = Opportunities::ExtractionService.new(opportunity)
-
result = service.extract
-
-
if result[:success]
-
Rails.logger.info("Successfully extracted data for opportunity #{opportunity_id}")
-
-
# If we found a job URL, we could optionally trigger job listing scraping
-
# For now, we just store the extracted data
-
if opportunity.reload.job_url.present?
-
Rails.logger.info("Opportunity #{opportunity_id} has job URL: #{opportunity.job_url}")
-
end
-
else
-
Rails.logger.warn("Failed to extract data for opportunity #{opportunity_id}: #{result[:error]}")
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# Background job for extracting actionable signals from synced emails
-
#
-
# Runs AI extraction on synced emails to extract company info, recruiter details,
-
# job information, and suggested actions. Also triggers automated email processors
-
# to create interview rounds, update statuses, and capture feedback.
-
#
-
# @example
-
# ProcessSignalExtractionJob.perform_later(synced_email.id)
-
#
-
class ProcessSignalExtractionJob < ApplicationJob
-
queue_as :default
-
-
# Retry on transient failures
-
retry_on StandardError, wait: :polynomially_longer, attempts: 3
-
-
# Don't retry on permanent failures
-
discard_on ActiveRecord::RecordNotFound
-
-
# Process the email with AI signal extraction
-
#
-
# @param synced_email_id [Integer] The synced email ID to process
-
# @param run_id [Integer, nil] Optional Signals::EmailPipelineRun id
-
# @return [void]
-
def perform(synced_email_id, run_id = nil)
-
@synced_email = SyncedEmail.find(synced_email_id)
-
@run_id = run_id
-
-
new_pipeline_enabled =
-
Setting.signals_decision_shadow_enabled? ||
-
Setting.signals_decision_execution_enabled? ||
-
Setting.signals_email_facts_extraction_enabled?
-
-
run = Signals::EmailPipelineRun.find_by(id: run_id) if run_id.present?
-
run ||= Signals::EmailPipelineRun.create!(
-
synced_email: @synced_email,
-
user: @synced_email.user,
-
connected_account: @synced_email.connected_account,
-
status: :started,
-
trigger: run_id.present? ? "gmail_sync" : "manual",
-
mode: "mixed",
-
started_at: Time.current,
-
metadata: { "source" => "process_signal_extraction_job" }
-
)
-
recorder = Signals::Observability::EmailPipelineRecorder.for_run(run)
-
-
# Legacy signal extraction is not the same thing as the new Facts/Decision pipeline.
-
# If the new pipeline is enabled, we still want to run it even if legacy extraction
-
# already ran (or was skipped).
-
if @synced_email.extraction_completed? && !new_pipeline_enabled
-
recorder&.finish_success!(metadata: { "final" => "skipped", "reason" => "already_extracted" })
-
return
-
end
-
-
if @synced_email.extraction_status == "skipped" && !new_pipeline_enabled
-
recorder&.finish_success!(metadata: { "final" => "skipped", "reason" => "extraction_status_skipped" })
-
return
-
end
-
-
Rails.logger.info("Processing signal extraction for email #{synced_email_id}")
-
-
legacy_result = nil
-
if Setting.signals_email_facts_extraction_enabled?
-
# When EmailFacts is enabled, legacy signal extraction is optional and should not block.
-
legacy_result = { success: false, skipped: true, reason: "signals_email_facts_extraction_enabled" }
-
recorder&.event!(
-
event_type: :legacy_signal_extraction,
-
status: :skipped,
-
input_payload: { "synced_email_id" => synced_email_id },
-
output_payload: { "skipped" => true, "reason" => legacy_result[:reason] }
-
)
-
else
-
# Run legacy AI extraction (service handles its own error notification)
-
service = Signals::ExtractionService.new(@synced_email)
-
legacy_result = recorder&.measure(
-
:legacy_signal_extraction,
-
input_payload: { "synced_email_id" => synced_email_id, "email_type" => @synced_email.email_type, "matched" => @synced_email.matched? },
-
output_payload_override: lambda { |r|
-
{
-
"success" => r[:success],
-
"skipped" => r[:skipped],
-
"reason" => r[:reason],
-
"error" => r[:error]
-
}.compact
-
}
-
) { service.extract } || service.extract
-
end
-
-
legacy_ok = legacy_result[:success]
-
-
# Always attempt the new pipeline when enabled, even if legacy extraction failed.
-
if Setting.signals_decision_shadow_enabled?
-
Signals::Decisioning::ShadowRunner.new(@synced_email, pipeline_run: run).call
-
end
-
-
# Optional execution mode (guarded by Setting + semantic validation).
-
executed = false
-
if Setting.signals_decision_execution_enabled?
-
executed = Signals::Decisioning::ExecutionRunner.new(@synced_email, pipeline_run: run).call
-
end
-
-
# Single-writer gate:
-
# - If the new execution runner successfully applied a plan, do NOT also run legacy orchestration.
-
# - Otherwise, fall back to the legacy system ONLY if legacy extraction succeeded.
-
if executed
-
Rails.logger.info("Signals decision execution applied; skipping legacy orchestration for email #{synced_email_id}")
-
recorder&.finish_success!(metadata: { "final" => "executed_new" })
-
return
-
end
-
-
if legacy_ok
-
Rails.logger.info("Successfully extracted signals for email #{synced_email_id}")
-
@synced_email.reload
-
Rails.logger.info(" Company: #{@synced_email.signal_company_name}") if @synced_email.signal_company_name.present?
-
-
recorder&.measure(
-
:legacy_orchestrator,
-
input_payload: { "synced_email_id" => synced_email_id, "email_type" => @synced_email.email_type, "matched" => @synced_email.matched? },
-
output_payload_override: lambda { |r| { "result" => r } }
-
) { process_email_actions(@synced_email) }
-
recorder&.finish_success!(metadata: { "final" => "executed_legacy" })
-
return
-
end
-
-
if legacy_result[:skipped]
-
Rails.logger.info("Skipped legacy signal extraction for email #{synced_email_id}: #{legacy_result[:reason]}")
-
recorder&.finish_success!(metadata: { "final" => new_pipeline_enabled ? "new_pipeline_no_execution" : "skipped", "reason" => legacy_result[:reason] })
-
return
-
end
-
-
# Legacy extraction failed and we didn't execute a new plan.
-
if new_pipeline_enabled
-
Rails.logger.warn("Legacy signal extraction failed but new pipeline enabled; continuing. email=#{synced_email_id} error=#{legacy_result[:error]}")
-
recorder&.finish_success!(metadata: { "final" => "new_pipeline_no_execution", "legacy_error" => legacy_result[:error] }.compact)
-
else
-
Rails.logger.warn("Failed to extract signals for email #{synced_email_id}: #{legacy_result[:error]}")
-
recorder&.finish_failed!(RuntimeError.new(legacy_result[:error].to_s), metadata: { "final" => "failed_legacy_extraction" })
-
end
-
rescue StandardError => e
-
begin
-
if defined?(recorder) && recorder
-
recorder.finish_failed!(e, metadata: { "final" => "exception" })
-
end
-
rescue StandardError
-
# best-effort only
-
end
-
# Note: Individual services (ExtractionService, processors) handle their own error notifications.
-
# This catch is for unexpected errors outside the service calls.
-
handle_error(e,
-
context: "signal_extraction",
-
user: @synced_email&.user,
-
synced_email_id: synced_email_id
-
)
-
end
-
-
private
-
-
# Processes automated actions based on email type
-
# Note: Individual processors handle their own error notifications.
-
#
-
# @param synced_email [SyncedEmail]
-
def process_email_actions(synced_email)
-
return unless synced_email.matched?
-
-
Rails.logger.info("Processing automated actions for email #{synced_email.id} (type: #{synced_email.email_type})")
-
-
orchestrator_result = Signals::EmailStateOrchestrator.new(synced_email).call
-
-
# Always try to capture feedback if available
-
feedback_result = process_company_feedback(synced_email) if synced_email.matched?
-
-
{ "orchestrator" => orchestrator_result, "company_feedback" => feedback_result }.compact
-
end
-
-
# Processes company feedback capture
-
#
-
# @param synced_email [SyncedEmail]
-
def process_company_feedback(synced_email)
-
processor = Signals::CompanyFeedbackProcessor.new(synced_email)
-
result = processor.process
-
-
if result[:success]
-
Rails.logger.info("Captured company feedback from email #{synced_email.id}")
-
elsif result[:skipped]
-
# Don't log skipped feedback - this is common
-
else
-
Rails.logger.warn("Failed to capture company feedback: #{result[:error]}")
-
end
-
result
-
end
-
end
-
# frozen_string_literal: true
-
-
# Purges soft-deleted interview applications that have been in Deleted for longer than the retention period.
-
#
-
# Intended to be run on a schedule (e.g. daily) via Solid Queue / cron / deploy scheduler.
-
class PurgeDeletedInterviewApplicationsJob < ApplicationJob
-
queue_as :default
-
-
# @param retention_period [ActiveSupport::Duration] Time to retain deleted records before hard deletion.
-
def perform(retention_period: 3.months)
-
cutoff = Time.current - retention_period
-
-
InterviewApplication.deleted.where("deleted_at < ?", cutoff).find_each do |application|
-
application.destroy!
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# Background job to enqueue fit recomputation for items impacted by a job listing update.
-
class RecomputeFitAssessmentsForJobListingJob < ApplicationJob
-
queue_as :default
-
-
discard_on ActiveRecord::RecordNotFound
-
-
# @param job_listing_id [Integer]
-
def perform(job_listing_id)
-
job_listing = JobListing.find(job_listing_id)
-
-
InterviewApplication.where(job_listing_id: job_listing.id).find_each do |application|
-
ComputeFitAssessmentJob.perform_later(application.user_id, "InterviewApplication", application.id)
-
end
-
-
# Saved jobs created from a pasted URL.
-
SavedJob.active.where(url: job_listing.url).find_each do |saved_job|
-
ComputeFitAssessmentJob.perform_later(saved_job.user_id, "SavedJob", saved_job.id)
-
end
-
-
# Opportunities with a matching job URL.
-
Opportunity.where(job_url: job_listing.url).find_each do |opportunity|
-
ComputeFitAssessmentJob.perform_later(opportunity.user_id, "Opportunity", opportunity.id)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# Background job to enqueue fit recomputation for all relevant items for a user.
-
class RecomputeFitAssessmentsForUserJob < ApplicationJob
-
queue_as :default
-
-
discard_on ActiveRecord::RecordNotFound
-
-
# @param user_id [Integer]
-
def perform(user_id)
-
user = User.find(user_id)
-
-
user.opportunities.actionable.find_each do |opportunity|
-
ComputeFitAssessmentJob.perform_later(user.id, "Opportunity", opportunity.id)
-
end
-
-
user.saved_jobs.active.unconverted.find_each do |saved_job|
-
ComputeFitAssessmentJob.perform_later(user.id, "SavedJob", saved_job.id)
-
end
-
-
user.interview_applications.active.find_each do |application|
-
ComputeFitAssessmentJob.perform_later(user.id, "InterviewApplication", application.id)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# Background job for proactively refreshing OAuth tokens before they expire
-
# This helps prevent sync failures due to expired tokens
-
class RefreshOauthTokensJob < ApplicationJob
-
queue_as :default
-
-
# Refreshes OAuth tokens that are about to expire
-
# This job runs periodically to ensure tokens are fresh before sync jobs need them
-
def perform
-
# Find accounts with tokens expiring within the next hour
-
# that haven't been marked as needing reauth
-
accounts_to_refresh = ConnectedAccount.google
-
.sync_enabled
-
.ready_for_sync
-
.expiring_soon
-
-
Rails.logger.info "Starting proactive token refresh for #{accounts_to_refresh.count} accounts"
-
-
refreshed_count = 0
-
failed_count = 0
-
-
accounts_to_refresh.find_each do |account|
-
refresh_account_token(account)
-
refreshed_count += 1
-
rescue Signet::AuthorizationError => e
-
handle_refresh_failure(account, e)
-
failed_count += 1
-
rescue StandardError => e
-
Rails.logger.error "Unexpected error refreshing token for account #{account.id}: #{e.message}"
-
failed_count += 1
-
end
-
-
Rails.logger.info "Token refresh completed: #{refreshed_count} refreshed, #{failed_count} failed"
-
end
-
-
private
-
-
# Refreshes the token for a single account
-
#
-
# @param account [ConnectedAccount] The account to refresh
-
def refresh_account_token(account)
-
return unless account.refreshable?
-
-
client_service = Gmail::ClientService.new(account)
-
client_service.send(:refresh_token!)
-
-
Rails.logger.debug "Successfully refreshed token for account #{account.id}"
-
end
-
-
# Handles a failed token refresh
-
#
-
# @param account [ConnectedAccount] The account that failed to refresh
-
# @param error [Signet::AuthorizationError] The error
-
def handle_refresh_failure(account, error)
-
Rails.logger.warn "Token refresh failed for account #{account.id}: #{error.message}"
-
-
# Mark the account as needing reauthorization
-
account.mark_needs_reauth!(error.message)
-
-
# Notify the user
-
ConnectedAccountMailer.reauth_required(account).deliver_later
-
end
-
end
-
# frozen_string_literal: true
-
-
# Background job to scrape job listing details from a URL
-
#
-
# Uses the Scraping::OrchestratorService to extract data via API or AI.
-
# Implements automatic retries with exponential backoff and DLQ handling.
-
class ScrapeJobListingJob < ApplicationJob
-
queue_as :default
-
-
# Don't retry on serialization errors
-
discard_on ActiveJob::DeserializationError
-
-
# Note: We use manual retry logic instead of retry_on to prevent
-
# exponential job growth. Retries are handled via ScrapingAttempt
-
# and manual perform_later scheduling with attempt tracking.
-
-
# Scrape job listing details using the orchestration service
-
#
-
# Supports smart retries using cached HTML when available.
-
#
-
# @param [JobListing] job_listing The job listing to scrape
-
# @param [Integer, nil] scraping_attempt_id Optional scraping attempt ID for retries
-
def perform(job_listing, scraping_attempt_id: nil)
-
return unless job_listing.url.present?
-
-
# If we have a scraping attempt ID, this is a retry - use RetryService
-
if scraping_attempt_id
-
attempt = ScrapingAttempt.find_by(id: scraping_attempt_id)
-
if attempt && (attempt.failed? || attempt.retrying?)
-
retry_with_service(attempt)
-
return
-
end
-
end
-
-
# Use the orchestrator service for extraction
-
orchestrator = Scraping::OrchestratorService.new(job_listing)
-
success = orchestrator.call
-
-
if success
-
Rails.logger.info({
-
event: "job_scraping_succeeded",
-
job_listing_id: job_listing.id,
-
url: job_listing.url
-
}.to_json)
-
-
# Job listing details may have changed; recompute fit scores for dependent items.
-
RecomputeFitAssessmentsForJobListingJob.perform_later(job_listing.id)
-
else
-
handle_extraction_failure(job_listing)
-
end
-
rescue => e
-
# Log the error with full context
-
Rails.logger.error({
-
event: "job_scraping_error",
-
job_listing_id: job_listing.id,
-
url: job_listing.url,
-
error: e.class.name,
-
message: e.message,
-
backtrace: e.backtrace&.first(5)
-
}.to_json)
-
-
# Handle failure and don't re-raise to prevent ActiveJob retry
-
handle_extraction_failure(job_listing)
-
end
-
-
private
-
-
# Retries extraction using RetryService with cached HTML
-
#
-
# @param [ScrapingAttempt] attempt The failed attempt
-
def retry_with_service(attempt)
-
retry_service = Scraping::RetryService.new(attempt)
-
-
# Determine which step to retry based on failed_step
-
result = case attempt.failed_step
-
when "html_fetch"
-
retry_service.retry_html_fetch
-
when "api_extraction", "ai_extraction"
-
retry_service.retry_extraction
-
else
-
# Unknown step or orchestration failure - retry full
-
retry_service.retry_full
-
end
-
-
if result[:success]
-
Rails.logger.info({
-
event: "job_scraping_retry_succeeded",
-
scraping_attempt_id: attempt.id,
-
job_listing_id: attempt.job_listing_id,
-
failed_step: attempt.failed_step
-
}.to_json)
-
else
-
handle_retry_failure(attempt)
-
end
-
rescue => e
-
Rails.logger.error({
-
event: "job_scraping_retry_error",
-
scraping_attempt_id: attempt.id,
-
job_listing_id: attempt.job_listing_id,
-
error: e.class.name,
-
message: e.message
-
}.to_json)
-
handle_retry_failure(attempt)
-
# Don't re-raise to prevent ActiveJob retry
-
end
-
-
# Handles extraction failure and schedules retries
-
#
-
# @param [JobListing] job_listing The job listing
-
def handle_extraction_failure(job_listing)
-
attempt = job_listing.scraping_attempts.order(created_at: :desc).first
-
-
if attempt
-
classifier = Scraping::FailureClassifierService.new(attempt)
-
unless classifier.retryable?
-
Rails.logger.info({
-
event: "job_scraping_not_retryable",
-
job_listing_id: job_listing.id,
-
scraping_attempt_id: attempt.id,
-
url: job_listing.url,
-
failed_step: attempt.failed_step,
-
error_message: attempt.error_message,
-
retry_count: attempt.retry_count
-
}.to_json)
-
return
-
end
-
-
# Use retry_count from attempt, not executions (which is from ActiveJob)
-
current_retry_count = attempt.retry_count || 0
-
-
# After 3 attempts, send to DLQ
-
if current_retry_count >= 3
-
attempt.send_to_dlq!
-
notify_admin_of_dlq(attempt)
-
-
Rails.logger.error({
-
event: "job_scraping_sent_to_dlq",
-
job_listing_id: job_listing.id,
-
scraping_attempt_id: attempt.id,
-
url: job_listing.url,
-
failed_step: attempt.failed_step,
-
retry_count: current_retry_count
-
}.to_json)
-
else
-
# Increment retry count
-
attempt.update(retry_count: current_retry_count + 1)
-
attempt.retry_attempt!
-
-
# Schedule retry with attempt ID for smart retry
-
ScrapeJobListingJob.perform_later(job_listing, scraping_attempt_id: attempt.id)
-
-
Rails.logger.warn({
-
event: "job_scraping_retry_scheduled",
-
job_listing_id: job_listing.id,
-
scraping_attempt_id: attempt.id,
-
url: job_listing.url,
-
failed_step: attempt.failed_step,
-
retry_count: current_retry_count + 1,
-
max_attempts: 3
-
}.to_json)
-
end
-
end
-
-
# Don't re-raise - we handle retries manually to prevent ActiveJob retry
-
end
-
-
# Handles retry failure
-
#
-
# @param [ScrapingAttempt] attempt The failed attempt
-
def handle_retry_failure(attempt)
-
classifier = Scraping::FailureClassifierService.new(attempt)
-
unless classifier.retryable?
-
Rails.logger.info({
-
event: "job_scraping_retry_not_retryable",
-
scraping_attempt_id: attempt.id,
-
job_listing_id: attempt.job_listing_id,
-
failed_step: attempt.failed_step,
-
error_message: attempt.error_message,
-
retry_count: attempt.retry_count
-
}.to_json)
-
return
-
end
-
-
current_retry_count = attempt.retry_count || 0
-
-
if current_retry_count >= 3
-
attempt.send_to_dlq!
-
notify_admin_of_dlq(attempt)
-
-
Rails.logger.error({
-
event: "job_scraping_retry_sent_to_dlq",
-
scraping_attempt_id: attempt.id,
-
job_listing_id: attempt.job_listing_id,
-
failed_step: attempt.failed_step,
-
retry_count: current_retry_count
-
}.to_json)
-
else
-
# Increment retry count
-
attempt.update(retry_count: current_retry_count + 1)
-
attempt.retry_attempt!
-
ScrapeJobListingJob.perform_later(attempt.job_listing, scraping_attempt_id: attempt.id)
-
-
Rails.logger.warn({
-
event: "job_scraping_retry_rescheduled",
-
scraping_attempt_id: attempt.id,
-
job_listing_id: attempt.job_listing_id,
-
failed_step: attempt.failed_step,
-
retry_count: current_retry_count + 1
-
}.to_json)
-
end
-
-
# Don't re-raise - we handle retries manually to prevent ActiveJob retry
-
end
-
-
# Notifies admin of items in the DLQ
-
#
-
# @param [ScrapingAttempt] attempt The failed attempt
-
def notify_admin_of_dlq(attempt)
-
# TODO: Implement admin notification
-
# Could send email, Slack message, or other notification
-
Rails.logger.info({
-
event: "admin_notification_sent",
-
scraping_attempt_id: attempt.id,
-
job_listing_id: attempt.job_listing_id,
-
notification_type: "dlq"
-
}.to_json)
-
end
-
end
-
# frozen_string_literal: true
-
-
# Provides a wrapper for exception notifier systems.
-
#
-
# This class provides a centralized interface for exception handling,
-
# supporting Sentry, Bugsnag, and email notifications with rich context.
-
#
-
# @example
-
# ExceptionNotifier.notify(exception, {
-
# context: 'ai_analysis',
-
# ai_provider: 'openai',
-
# analyzable_type: 'FeedbackPost',
-
# analyzable_id: 123
-
# })
-
#
-
class ExceptionNotifier
-
class << self
-
# Notify of an exception with context
-
#
-
# @param exception [Exception, Hash] The exception or error hash
-
# @param options [Hash] Additional context and metadata
-
# @option options [String] :context Error context (e.g., 'ai_analysis', 'payment')
-
# @option options [String] :severity Severity level ('error', 'warning', 'info')
-
# @option options [Hash] :user User information if available
-
# @option options [Hash] :ai_context AI-specific metadata for AI errors
-
def notify(exception, options = {})
-
unless exception.is_a?(Exception)
-
if exception.respond_to? :to_hash
-
exception = exception.to_hash
-
options.merge!(exception)
-
message = options.delete(:error_message) || "error in #{options.delete(:error_class)}"
-
exception = RuntimeError.new(message)
-
else
-
exception = RuntimeError.new("Unexpected error")
-
end
-
end
-
-
payload = extract_i18n_context(exception)
-
payload = extract_ai_context(options, payload) if options[:ai_context]
-
-
if Rails.env.development?
-
log_development_error(exception, options)
-
else
-
notify_exception(exception, options, payload)
-
end
-
end
-
-
# Notify AI-specific errors with rich context
-
#
-
# @param exception [Exception] The exception
-
# @param ai_context [Hash] AI-specific metadata
-
# @option ai_context [String] :operation AI operation type (sentiment, entity, summary)
-
# @option ai_context [String] :provider_name Provider name
-
# @option ai_context [String] :model_identifier Model identifier
-
# @option ai_context [Integer] :prompt_id Prompt ID
-
# @option ai_context [String] :analyzable_type Type of content analyzed
-
# @option ai_context [Integer] :analyzable_id ID of content analyzed
-
# @option ai_context [Integer] :account_id Account ID
-
def notify_ai_error(exception, ai_context = {})
-
notify(exception, {
-
context: "ai_#{ai_context[:operation]}",
-
severity: ai_context[:severity] || "error",
-
ai_context: ai_context,
-
tags: {
-
ai_operation: ai_context[:operation],
-
ai_provider: ai_context[:provider_name],
-
ai_model: ai_context[:model_identifier]
-
}
-
})
-
end
-
-
private
-
-
def notify_exception(exception, options = {}, payload)
-
notify_sentry(exception, options, payload) if Setting.sentry_enabled? && defined?(Sentry)
-
notify_bugsnag(exception, options, payload) if Setting.bugsnag_enabled? && defined?(Bugsnag)
-
end
-
-
def notify_sentry(exception, options = {}, payload)
-
return unless defined?(Sentry)
-
-
Sentry.with_scope do |scope|
-
scope.set_context("context", options)
-
scope.set_context(:payload, payload) if payload.present?
-
-
# Set AI-specific context if present
-
scope.set_context("ai", options[:ai_context]) if options[:ai_context]
-
-
# Set tags (used for AI and non-AI contexts like billing)
-
scope.set_tags(options[:tags]) if options[:tags].present?
-
-
# Set severity level
-
scope.set_level(options[:severity]) if options[:severity]
-
-
# Set user context
-
if defined?(Current) && Current.respond_to?(:user) && Current.user.present?
-
current_email = Current.user.try(:email_address) || Current.user.try(:email)
-
scope.set_user(email: current_email, id: Current.user.id)
-
elsif options[:user].present?
-
scope.set_user(email: options[:user][:email], id: options[:user][:id])
-
end
-
-
Sentry.capture_exception(exception)
-
end
-
end
-
-
def notify_bugsnag(exception, options = {}, payload)
-
return unless defined?(Bugsnag)
-
-
if payload.blank?
-
return Bugsnag.notify(exception) do |event|
-
options.each { |option, data| event.add_metadata(option, data) }
-
end
-
end
-
-
Bugsnag.notify(exception) do |event|
-
event.add_metadata(:context, payload)
-
options.each { |option, data| event.add_metadata(option, data) }
-
end
-
end
-
-
def extract_i18n_context(exception, payload = {})
-
return payload unless exception.is_a?(::I18n::MissingTranslationData)
-
-
payload[:translation] = exception&.key
-
payload[:translation_options] = exception&.options
-
payload
-
end
-
-
# Extract AI-specific context from options
-
#
-
# @param options [Hash] Options hash with ai_context
-
# @param payload [Hash] Existing payload
-
# @return [Hash] Enhanced payload with AI context
-
def extract_ai_context(options, payload = {})
-
ai_ctx = options[:ai_context] || {}
-
-
payload[:ai_operation] = ai_ctx[:operation]
-
payload[:ai_provider] = ai_ctx[:provider_name]
-
payload[:ai_model] = ai_ctx[:model_identifier]
-
payload[:ai_prompt_id] = ai_ctx[:prompt_id]
-
payload[:analyzable_type] = ai_ctx[:analyzable_type]
-
payload[:analyzable_id] = ai_ctx[:analyzable_id]
-
payload[:account_id] = ai_ctx[:account_id]
-
payload[:tokens_used] = ai_ctx[:tokens_used]
-
payload[:processing_time_ms] = ai_ctx[:processing_time_ms]
-
-
payload.compact
-
end
-
-
# Log error in development with better formatting
-
#
-
# @param exception [Exception] The exception
-
# @param options [Hash] Additional context
-
def log_development_error(exception, options)
-
Rails.logger.error "\n" + ("=" * 80)
-
Rails.logger.error "EXCEPTION: #{exception.class}"
-
Rails.logger.error "MESSAGE: #{exception.message}"
-
Rails.logger.error "CONTEXT: #{options[:context]}" if options[:context]
-
-
if options[:ai_context]
-
Rails.logger.error "AI OPERATION: #{options[:ai_context][:operation]}"
-
Rails.logger.error "AI PROVIDER: #{options[:ai_context][:provider_name]}"
-
Rails.logger.error "AI MODEL: #{options[:ai_context][:model_identifier]}"
-
end
-
-
Rails.logger.error "\nBACKTRACE:"
-
Rails.logger.error exception.backtrace&.first(10)&.join("\n")
-
Rails.logger.error "OPTIONS: #{options.except(:ai_context)}" if options.any?
-
Rails.logger.error "=" * 80 + "\n"
-
end
-
end
-
end
-
class ApplicationMailer < ActionMailer::Base
-
default from: "Gleania <noreply@gleania.com>"
-
layout "mailer"
-
-
# Attach logo as inline attachment for all emails
-
before_action :attach_logo
-
-
private
-
-
def attach_logo
-
logo_path = Rails.root.join("app/assets/images/logo/logo.png")
-
if File.exist?(logo_path)
-
attachments.inline["logo.png"] = File.read(logo_path)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# Mailer for connected account notifications
-
class ConnectedAccountMailer < ApplicationMailer
-
# Sends a notification when a connected account needs reauthorization
-
#
-
# @param connected_account [ConnectedAccount] The account that needs reauth
-
def reauth_required(connected_account)
-
@account = connected_account
-
@user = connected_account.user
-
-
mail(
-
to: @user.email_address,
-
subject: "Action Required: Reconnect your Gmail account"
-
)
-
end
-
end
-
class PasswordsMailer < ApplicationMailer
-
def reset(user)
-
@user = user
-
mail subject: "Reset your password", to: user.email_address
-
end
-
end
-
class UserMailer < ApplicationMailer
-
# Sends email verification link to user
-
# @param user [User] The user to send verification to
-
def verify_email(user)
-
@user = user
-
@verification_url = email_verification_url(@user.generate_token_for(:email_verification))
-
-
mail(
-
to: user.email_address,
-
subject: "Verify your Gleania account"
-
)
-
end
-
-
# Sends welcome email after user verifies their email
-
# @param user [User] The newly verified user
-
def welcome(user)
-
@user = user
-
-
mail(
-
to: user.email_address,
-
subject: "Welcome to Gleania! 🎉"
-
)
-
end
-
end
-
# frozen_string_literal: true
-
-
# Module namespace for AI-related models
-
#
-
# Contains:
-
# - Ai::LlmPrompt (STI base for prompt templates)
-
# - Ai::JobExtractionPrompt (job listing extraction prompts)
-
# - Ai::EmailExtractionPrompt (recruiter email extraction prompts)
-
# - Ai::ResumeSkillExtractionPrompt (resume skill extraction prompts)
-
# - Ai::LlmApiLog (API call logging)
-
#
-
1
module Ai
-
end
-
-
-
-
-
# frozen_string_literal: true
-
-
module Ai
-
# Prompt template for proposing long-term user memories (always user-confirmed).
-
#
-
# Variables:
-
# - {{messages}}
-
class AssistantMemoryProposalPrompt < LlmPrompt
-
def self.default_prompt_template
-
<<~PROMPT
-
You are extracting durable user preferences/goals/constraints that should be remembered across chats.
-
-
Only propose items that are explicitly stated by the user. Do not infer sensitive attributes.
-
-
Output JSON only:
-
{
-
"items": [
-
{ "key": "string", "value": { }, "reason": "string", "confidence": 0.0 }
-
]
-
}
-
-
Keys should be stable and namespaced (examples):
-
- preferences.tone
-
- goals.target_role
-
- constraints.timezone
-
- preferences.focus_areas
-
-
Recent messages:
-
{{messages}}
-
PROMPT
-
end
-
-
def self.default_system_prompt
-
<<~PROMPT
-
You are extracting durable user preferences/goals/constraints that should be remembered across chats.
-
Only propose items that are explicitly stated by the user. Do not infer sensitive attributes.
-
Return only valid JSON. Do not include markdown or extra commentary.
-
PROMPT
-
end
-
-
def self.default_variables
-
{
-
"messages" => { "required" => true, "description" => "Recent messages to extract explicit preferences/goals from" }
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Ai
-
# System prompt for the in-app Assistant.
-
#
-
# Unlike extraction prompts (which use prompt_template for user content with variables),
-
# the assistant chat uses:
-
# - system_prompt: LLM behavior instructions (role, rules, formatting)
-
# - No prompt_template variables - the user's question IS the content
-
#
-
# The full system prompt sent to the LLM is built by LlmResponder as:
-
#
-
# [System Prompt] + [User Context Section]
-
#
-
# Where User Context is dynamically injected and includes:
-
# - User profile (name, account age)
-
# - Career context (resume summary, work history, career targets)
-
# - Skills summary (top skills)
-
# - Pipeline status (application count, recent applications)
-
# - Page context (current page the user is viewing)
-
#
-
# The context is built by Assistant::Context::Builder and formatted
-
# by LlmResponder.format_context_for_prompt before being appended.
-
#
-
# @see Assistant::Context::Builder
-
# @see Assistant::Chat::Components::LlmResponder#build_system_prompt_with_context
-
class AssistantSystemPrompt < LlmPrompt
-
# System prompt defining the assistant's behavior, rules, and formatting.
-
# This is sent as the system message to the LLM.
-
#
-
# @return [String] Default system prompt
-
def self.default_system_prompt
-
<<~PROMPT
-
You are Gleania, an intelligent assistant embedded inside the Gleania web app which helps the user with their job search, interview tracking & preparation, gathering feedback across interviews, skill analysis and career development.
-
-
You help the user with:
-
- interview preparation and debriefs
-
- understanding their skill profile and gaps
-
- analyzing job listings and fit
-
- organizing and updating their pipeline
-
- providing insights and recommendations for career development
-
-
Rules:
-
- Use the USER CONTEXT section below to personalize your responses.
-
- If needed data is missing from context, ask a clarifying question.
-
- Never claim you executed an action unless the system explicitly confirms it.
-
- Use tools when they help you answer with up-to-date or user-specific data.
-
- For write actions, only proceed after explicit user confirmation in the UI.
-
- Keep responses concise, structured, and actionable.
-
- When discussing the user's resume, work history, or skills, reference the specific details from their context.
-
-
Formatting:
-
- Use **Markdown** for your responses.
-
- Use headers (##, ###) to organize longer responses.
-
- Use bullet points (-) or numbered lists for steps and options.
-
- Use **bold** for emphasis and `inline code` for technical terms.
-
- Use fenced code blocks with language identifiers for code examples.
-
- Keep paragraphs short and readable.
-
PROMPT
-
end
-
-
# Prompt template - not used for assistant chat since user provides their own question.
-
# This exists for DB schema compatibility with LlmPrompt base class.
-
#
-
# @return [String] Empty prompt template
-
def self.default_prompt_template
-
"Assistant chat - user provides the message content directly."
-
end
-
-
def self.default_variables
-
{}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Ai
-
# Prompt template for summarizing an assistant thread.
-
#
-
# Variables:
-
# - {{existing_summary}}
-
# - {{messages}}
-
class AssistantThreadSummaryPrompt < LlmPrompt
-
def self.default_prompt_template
-
<<~PROMPT
-
You are summarizing an assistant chat thread. Produce a concise summary that preserves:
-
- user goals and constraints
-
- decisions and commitments
-
- key context needed to continue the conversation
-
-
Existing summary (may be empty):
-
{{existing_summary}}
-
-
New messages (role and content):
-
{{messages}}
-
-
Output only the updated summary text.
-
PROMPT
-
end
-
-
def self.default_system_prompt
-
<<~PROMPT
-
You are summarizing an assistant chat thread. Your goal is to produce a concise summary that preserves:
-
- user goals and constraints
-
- decisions and commitments
-
- key context needed to continue the conversation
-
You should only return the updated summary text.
-
Do not include markdown or extra commentary.
-
Do not include any other text or formatting.
-
Do not include any other text or formatting.
-
PROMPT
-
end
-
-
def self.default_variables
-
{
-
"existing_summary" => { "required" => false, "description" => "Current summary text" },
-
"messages" => { "required" => true, "description" => "Recent messages to incorporate" }
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Ai
-
# Prompt template for extracting job opportunity data from recruiter emails
-
#
-
# Used by Opportunities::ExtractionService to extract structured opportunity
-
# data from email content (subject + body).
-
#
-
# Variables:
-
# - {{subject}} - The email subject line
-
# - {{body}} - The email body content
-
#
-
class EmailExtractionPrompt < LlmPrompt
-
# Default prompt template for email extraction
-
#
-
# @return [String] Default prompt template
-
def self.default_prompt_template
-
<<~PROMPT
-
Analyze the following email content and extract structured information about the job opportunity.
-
The email may be a direct message from a recruiter, a forwarded or a direct message from LinkedIn, or a referral.
-
-
EMAIL SUBJECT: {{subject}}
-
-
EMAIL CONTENT:
-
{{body}}
-
-
Extract the following information and respond with a JSON object:
-
-
{
-
"company_name": "The company with the job opening (not the recruiting agency unless they are the employer)",
-
"company_domain": "The industry/domain the company operates in (e.g., 'FinTech', 'SaaS', 'Healthcare', 'E-commerce', 'EdTech', 'AI/ML', 'Cybersecurity', 'Enterprise Software', 'B2B', 'B2C', etc. - use null if unclear)",
-
"job_role_title": "The job title or role being offered",
-
"job_role_department": "The department/function this role belongs to (one of: 'Engineering', 'Product', 'Design', 'Data Science', 'DevOps/SRE', 'Sales', 'Marketing', 'Customer Success', 'Finance', 'HR/People', 'Legal', 'Operations', 'Executive', 'Research', 'QA/Testing', 'Security', 'IT', 'Content', 'Other')",
-
"job_url": "URL to the job listing or application page (if found in the email)",
-
"all_links": [
-
{"url": "...", "type": "job_posting|company_website|calendar|linkedin|other", "description": "brief description"}
-
],
-
"recruiter_info": {
-
"name": "Recruiter's name",
-
"title": "Recruiter's job title",
-
"company": "Recruiting company or agency name"
-
},
-
"key_details": "A brief summary of important details like: location, remote/hybrid/onsite, salary range, tech stack, company stage, team size, etc.",
-
"is_forwarded": true/false,
-
"original_source": "linkedin|email|referral|other",
-
"confidence_score": 0.0 to 1.0, # Confidence score for the overall extraction
-
"potential_opportunity": true/false, # Whether this is a potential opportunity or just a generic/newletter type email
-
"potential_opportunity_confidence_score": 0.0 to 1.0, # Confidence score for the potential opportunity
-
"potential_opportunity_confidence_reasoning": "The reasoning for why this is a potential opportunity or not, if potential_opportunity is false, this should be null"
-
}
-
-
Guidelines:
-
- If information is not clearly stated, use null instead of guessing
-
- For job_url, only include URLs that lead to job listings or application pages
-
- Distinguish between the hiring company and any recruiting agency
-
- Look for LinkedIn message indicators, forwarded email markers
-
- Extract all relevant links even if they're not the main job posting
-
- confidence_score should reflect how confident you are in the extracted information
-
-
Respond ONLY with the JSON object, no additional text.
-
PROMPT
-
end
-
-
def self.default_system_prompt
-
<<~PROMPT
-
You are an expert at extracting structured job opportunity information from recruiter emails.
-
Your goal is to return only valid JSON. Do not guess missing values; use null.
-
Do not include markdown or extra commentary.
-
Do not include any other text or formatting.
-
PROMPT
-
end
-
-
# Returns the expected variables for this prompt type
-
#
-
# @return [Hash] Variable definitions
-
def self.default_variables
-
{
-
"subject" => { "required" => true, "description" => "The email subject line" },
-
"body" => { "required" => true, "description" => "The email body content" }
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Ai
-
# Prompt template for extracting EmailFacts (unified workflow facts) from emails.
-
#
-
# Used by Signals::Facts::EmailFactsExtractor.
-
#
-
# Variables:
-
# - {{subject}}
-
# - {{body}}
-
# - {{from_email}}
-
# - {{from_name}}
-
# - {{email_type}} (legacy classifier hint, optional)
-
# - {{application_snapshot}} (JSON string, optional)
-
class EmailFactsExtractionPrompt < LlmPrompt
-
def self.default_prompt_template
-
<<~PROMPT
-
You are extracting workflow facts from a recruiting/interview email.
-
You MUST NOT guess. If information is not explicitly present, use null/false/empty.
-
-
FROM: {{from_name}} <{{from_email}}>
-
SUBJECT: {{subject}}
-
LEGACY_EMAIL_TYPE_HINT: {{email_type}}
-
-
APPLICATION SNAPSHOT (may be null):
-
{{application_snapshot}}
-
-
EMAIL BODY (canonicalized):
-
{{body}}
-
-
Return ONLY valid JSON matching this shape:
-
-
{
-
"extraction": { "provider": null, "model": null, "confidence": 0.0, "warnings": [] },
-
"classification": { "kind": "scheduling|interview_invite|round_feedback|status_update|application_confirmation|recruiter_outreach|interview_assessment|other|unknown", "confidence": 0.0, "evidence": ["..."] },
-
"entities": {
-
"company": { "name": null, "website": null },
-
"recruiter": { "name": null, "email": null, "title": null },
-
"job": { "title": null, "department": null, "location": null, "url": null }
-
},
-
"action_links": [{ "url": "...", "action_label": "...", "priority": 1 }],
-
"key_insights": null,
-
"is_forwarded": false,
-
"scheduling": {
-
"is_scheduling_related": false,
-
"scheduled_at": null,
-
"timezone_hint": null,
-
"duration_minutes": 0,
-
"stage": null,
-
"round_type": null,
-
"stage_name": null,
-
"interviewer_name": null,
-
"interviewer_role": null,
-
"video_link": null,
-
"phone_number": null,
-
"location": null,
-
"is_rescheduled": false,
-
"is_cancelled": false,
-
"original_scheduled_at": null,
-
"evidence": []
-
},
-
"round_feedback": {
-
"has_round_feedback": false,
-
"result": null,
-
"stage_mentioned": null,
-
"round_type": null,
-
"interviewer_mentioned": null,
-
"date_mentioned": null,
-
"feedback": { "has_detailed_feedback": false, "summary": null, "strengths": [], "improvements": [], "full_feedback_text": null },
-
"next_steps": { "has_next_round": false, "next_round_type": null, "next_round_hint": null, "timeline_hint": null },
-
"evidence": []
-
},
-
"status_change": {
-
"has_status_change": false,
-
"type": "rejection|offer|withdrawal|ghosted|on_hold|no_change|null",
-
"is_final": null,
-
"effective_date": null,
-
"rejection_details": { "reason": null, "stage_rejected_at": null, "is_generic": false, "door_open": false },
-
"offer_details": { "role_title": null, "department": null, "start_date": null, "response_deadline": null, "includes_compensation_info": false, "compensation_hints": null, "next_steps": null },
-
"feedback": { "has_feedback": false, "feedback_text": null, "is_constructive": false },
-
"evidence": []
-
}
-
}
-
-
Rules:
-
- Output ONLY JSON, no markdown, no commentary.
-
- Every evidence string MUST be a direct substring from the email body or subject.
-
- Include only up to 20 action_links. Prioritize schedule/join/apply links.
-
PROMPT
-
end
-
-
def self.default_system_prompt
-
<<~PROMPT
-
You extract structured facts for an email-driven interview workflow.
-
Be conservative. Do not infer hidden state. Do not guess.
-
Return only valid JSON.
-
PROMPT
-
end
-
-
def self.default_variables
-
{
-
"subject" => { "required" => true },
-
"body" => { "required" => true },
-
"from_email" => { "required" => false },
-
"from_name" => { "required" => false },
-
"email_type" => { "required" => false },
-
"application_snapshot" => { "required" => false }
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Ai
-
# Prompt template for extracting interview details from scheduling confirmation emails
-
#
-
# Used by Signals::InterviewRoundProcessor to extract structured interview data
-
# from confirmation emails (Calendly, GoodTime, manual, etc.)
-
#
-
# Variables:
-
# - {{subject}} - The email subject line
-
# - {{body}} - The email body content
-
# - {{from_email}} - The sender's email address
-
# - {{from_name}} - The sender's display name
-
# - {{company_name}} - The company name if known
-
#
-
class InterviewExtractionPrompt < LlmPrompt
-
# Default prompt template for interview extraction
-
#
-
# @return [String] Default prompt template
-
def self.default_prompt_template
-
<<~PROMPT
-
Analyze the following interview scheduling/confirmation email and extract interview details.
-
-
FROM: {{from_name}} <{{from_email}}>
-
SUBJECT: {{subject}}
-
COMPANY: {{company_name}}
-
-
EMAIL CONTENT:
-
{{body}}
-
-
Extract the following information and respond with a JSON object:
-
-
{
-
"interview": {
-
"scheduled_at": "ISO 8601 datetime (e.g., '2026-01-21T14:00:00-08:00') - MUST include timezone offset",
-
"duration_minutes": 30 or 45 or 60 (integer, extract from email or default to 30),
-
"timezone": "Timezone name (e.g., 'PST', 'EST', 'UTC') if mentioned",
-
"stage": "screening|technical|hiring_manager|culture_fit|other",
-
"stage_name": "Custom stage name if mentioned (e.g., 'Technical Round 1', 'Final Interview')"
-
},
-
"interviewer": {
-
"name": "Full name of the interviewer",
-
"role": "Job title/role of the interviewer (e.g., 'Engineering Manager', 'Senior Recruiter')",
-
"email": "Interviewer's email if mentioned"
-
},
-
"logistics": {
-
"video_link": "Full URL to video conference (Zoom, Meet, Teams, etc.)",
-
"phone_number": "Phone number if it's a phone interview",
-
"location": "Physical location if in-person interview",
-
"meeting_id": "Meeting ID/code if provided separately from link",
-
"passcode": "Meeting passcode if provided"
-
},
-
"confirmation_source": "calendly|goodtime|greenhouse|lever|manual|other",
-
"is_rescheduled": true/false,
-
"is_cancelled": true/false,
-
"original_scheduled_at": "ISO 8601 datetime if this is a reschedule",
-
"additional_instructions": "Any prep instructions, what to bring, who to ask for, etc.",
-
"confidence_score": 0.0 to 1.0
-
}
-
-
Guidelines for stage detection:
-
- "screening" - Initial recruiter call, HR screen, phone screen, intro call
-
- "technical" - Coding interview, system design, technical assessment, live coding
-
- "hiring_manager" - Meeting with manager, team lead, direct supervisor
-
- "culture_fit" - Values interview, behavioral, team fit, culture chat
-
- "other" - Final round, panel, presentation, case study, on-site
-
-
Guidelines for confirmation_source:
-
- "calendly" - Calendly scheduling links/confirmations
-
- "goodtime" - GoodTime scheduling platform
-
- "greenhouse" - Greenhouse ATS confirmations
-
- "lever" - Lever ATS confirmations
-
- "manual" - Direct email from recruiter/company (no scheduling platform)
-
- "other" - Other scheduling tools
-
-
Guidelines for date/time extraction:
-
- Always include timezone offset in scheduled_at (e.g., -08:00 for PST)
-
- If no timezone specified, use the timezone mentioned in the email or default to UTC
-
- Parse various date formats: "Tuesday, January 21st", "1/21/26", "21 Jan 2026"
-
- Parse various time formats: "2:00 PM", "14:00", "2pm PST"
-
-
Guidelines for video links:
-
- Extract the full video conference URL
-
- Common patterns: zoom.us/j/, meet.google.com/, teams.microsoft.com/
-
-
Use null for any field where information is not clearly available.
-
Respond ONLY with the JSON object, no additional text or markdown.
-
PROMPT
-
end
-
-
# Default system prompt for interview extraction
-
#
-
# @return [String]
-
def self.default_system_prompt
-
<<~PROMPT
-
You are an expert at extracting interview scheduling details from confirmation emails.
-
Your goal is to accurately extract:
-
- When the interview is scheduled (date, time, timezone)
-
- How long it will last
-
- Who the interviewer is
-
- How to join (video link, phone, location)
-
- What type/stage of interview it is
-
-
Rules:
-
- Return ONLY valid JSON, no markdown or commentary
-
- Use null for missing information, never guess
-
- Always include timezone in scheduled_at datetime
-
- Be precise with video conference URLs
-
- Detect rescheduling and cancellation language
-
PROMPT
-
end
-
-
# Returns the expected variables for this prompt type
-
#
-
# @return [Hash] Variable definitions
-
def self.default_variables
-
{
-
"subject" => { "required" => true, "description" => "The email subject line" },
-
"body" => { "required" => true, "description" => "The email body content" },
-
"from_email" => { "required" => true, "description" => "The sender's email address" },
-
"from_name" => { "required" => false, "description" => "The sender's display name" },
-
"company_name" => { "required" => false, "description" => "The company name if known" }
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Ai
-
# Prompt template for generating focused prep areas for an interview.
-
#
-
# Variables:
-
# - {{candidate_profile}}
-
# - {{job_context}}
-
# - {{interview_stage}}
-
# - {{feedback_themes}}
-
class InterviewPrepFocusAreasPrompt < LlmPrompt
-
def self.default_prompt_template
-
<<~PROMPT
-
You are Gleania, a calm, practical interview preparation coach.
-
You MUST NOT invent experience. If unknown, say "unknown" and avoid specifics.
-
-
TASK:
-
Generate 3-5 focused preparation areas for this specific role and interview stage.
-
For each item, provide: why it matters, how to prepare, and what experiences to use (only if inferable from profile).
-
-
CANDIDATE_PROFILE_JSON:
-
{{candidate_profile}}
-
-
JOB_CONTEXT_JSON:
-
{{job_context}}
-
-
INTERVIEW_STAGE:
-
{{interview_stage}}
-
-
FEEDBACK_THEMES_JSON:
-
{{feedback_themes}}
-
-
OUTPUT JSON ONLY:
-
{
-
"focus_areas": [
-
{
-
"title": "Short actionable title",
-
"why_it_matters": "1-2 sentences",
-
"how_to_prepare": ["bullet", "bullet"],
-
"experiences_to_use": ["bullet", "bullet"]
-
}
-
]
-
}
-
PROMPT
-
end
-
-
def self.default_system_prompt
-
<<~PROMPT
-
You are Gleania, a calm, practical interview preparation coach.
-
Focus on actionable preparation guidance. Do not invent experience.
-
PROMPT
-
end
-
-
def self.default_variables
-
{
-
"candidate_profile" => { "required" => true, "description" => "Candidate profile JSON" },
-
"job_context" => { "required" => true, "description" => "Job context JSON" },
-
"interview_stage" => { "required" => true, "description" => "Interview stage label" },
-
"feedback_themes" => { "required" => false, "description" => "Feedback themes JSON" }
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Ai
-
# Prompt template for generating interview prep match analysis.
-
#
-
# Variables:
-
# - {{candidate_profile}} - Structured candidate profile summary (JSON)
-
# - {{job_context}} - Job listing context and text (JSON)
-
# - {{interview_stage}} - Interview stage label (string)
-
# - {{feedback_themes}} - Prior feedback themes (JSON)
-
class InterviewPrepMatchPrompt < LlmPrompt
-
def self.default_prompt_template
-
<<~PROMPT
-
You are Gleania, a calm, practical interview preparation coach.
-
You MUST NOT invent experience. If something is unknown, say so.
-
-
TASK:
-
Given the candidate profile and the job context, produce a qualitative match analysis.
-
Avoid numeric scoring. Use one label only: "strong_match", "partial_match", or "stretch_role".
-
-
CANDIDATE_PROFILE_JSON:
-
{{candidate_profile}}
-
-
JOB_CONTEXT_JSON:
-
{{job_context}}
-
-
INTERVIEW_STAGE:
-
{{interview_stage}}
-
-
FEEDBACK_THEMES_JSON:
-
{{feedback_themes}}
-
-
OUTPUT JSON ONLY (no markdown, no extra text):
-
{
-
"match_label": "strong_match|partial_match|stretch_role",
-
"strong_in": ["..."],
-
"partial_in": ["..."],
-
"missing_or_risky": ["..."],
-
"notes": "1-3 sentences, grounded in provided data only"
-
}
-
PROMPT
-
end
-
-
def self.default_system_prompt
-
<<~PROMPT
-
You are Gleania, a calm, practical interview preparation coach.
-
Be concise, accurate, and grounded in the provided data.
-
Never invent experience; if unknown, say so.
-
PROMPT
-
end
-
-
def self.default_variables
-
{
-
"candidate_profile" => { "required" => true, "description" => "Candidate profile JSON" },
-
"job_context" => { "required" => true, "description" => "Job context JSON" },
-
"interview_stage" => { "required" => true, "description" => "Interview stage label" },
-
"feedback_themes" => { "required" => false, "description" => "Feedback themes JSON" }
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Ai
-
# Prompt template for generating contextual question framing guidance.
-
#
-
# Variables:
-
# - {{candidate_profile}}
-
# - {{job_context}}
-
# - {{interview_stage}}
-
# - {{feedback_themes}}
-
class InterviewPrepQuestionFramingPrompt < LlmPrompt
-
def self.default_prompt_template
-
<<~PROMPT
-
You are Gleania, a calm interview preparation coach.
-
You MUST NOT invent experience.
-
Do NOT write full scripted answers. Provide framing and outlines only.
-
-
TASK:
-
Provide 6-10 common questions for this role/stage, and how this candidate should FRAME their answers.
-
Include: framing bullets, a suggested outline, and common pitfalls.
-
-
CANDIDATE_PROFILE_JSON:
-
{{candidate_profile}}
-
-
JOB_CONTEXT_JSON:
-
{{job_context}}
-
-
INTERVIEW_STAGE:
-
{{interview_stage}}
-
-
FEEDBACK_THEMES_JSON:
-
{{feedback_themes}}
-
-
OUTPUT JSON ONLY:
-
{
-
"questions": [
-
{
-
"question": "…",
-
"framing": ["bullet", "bullet"],
-
"outline": ["bullet", "bullet"],
-
"pitfalls": ["bullet", "bullet"]
-
}
-
]
-
}
-
PROMPT
-
end
-
-
def self.default_system_prompt
-
<<~PROMPT
-
You are Gleania, a calm interview preparation coach.
-
Provide framing and outlines, not full scripted answers.
-
Never invent experience; do not claim specifics not in the profile.
-
PROMPT
-
end
-
-
def self.default_variables
-
{
-
"candidate_profile" => { "required" => true, "description" => "Candidate profile JSON" },
-
"job_context" => { "required" => true, "description" => "Job context JSON" },
-
"interview_stage" => { "required" => true, "description" => "Interview stage label" },
-
"feedback_themes" => { "required" => false, "description" => "Feedback themes JSON" }
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Ai
-
# Prompt template for generating strength positioning guidance.
-
#
-
# Variables:
-
# - {{candidate_profile}}
-
# - {{job_context}}
-
# - {{interview_stage}}
-
# - {{feedback_themes}}
-
class InterviewPrepStrengthPositioningPrompt < LlmPrompt
-
def self.default_prompt_template
-
<<~PROMPT
-
You are Gleania, a calm interview preparation coach.
-
You MUST NOT invent experience. Avoid claims without evidence.
-
-
TASK:
-
Identify 4-6 strengths the candidate should emphasize for this role and stage.
-
Each strength should include a positioning note and suggested evidence types (not fake examples).
-
-
CANDIDATE_PROFILE_JSON:
-
{{candidate_profile}}
-
-
JOB_CONTEXT_JSON:
-
{{job_context}}
-
-
INTERVIEW_STAGE:
-
{{interview_stage}}
-
-
FEEDBACK_THEMES_JSON:
-
{{feedback_themes}}
-
-
OUTPUT JSON ONLY:
-
{
-
"strengths": [
-
{
-
"title": "Strength to emphasize",
-
"positioning": "How to frame it in the interview (1-2 sentences)",
-
"evidence_types": ["project impact", "trade-offs", "ownership", "mentorship"]
-
}
-
]
-
}
-
PROMPT
-
end
-
-
def self.default_system_prompt
-
<<~PROMPT
-
You are Gleania, a calm interview preparation coach.
-
Help the candidate position real strengths credibly; avoid over-claiming.
-
Never invent experience.
-
PROMPT
-
end
-
-
def self.default_variables
-
{
-
"candidate_profile" => { "required" => true, "description" => "Candidate profile JSON" },
-
"job_context" => { "required" => true, "description" => "Job context JSON" },
-
"interview_stage" => { "required" => true, "description" => "Interview stage label" },
-
"feedback_themes" => { "required" => false, "description" => "Feedback themes JSON" }
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Ai
-
# Prompt template for extracting job listing data from HTML
-
#
-
# Used by Scraping::AiJobExtractorService to extract structured job data
-
# from scraped HTML content.
-
#
-
# Variables:
-
# - {{url}} - The job listing URL
-
# - {{html_content}} - The cleaned HTML content
-
#
-
class JobExtractionPrompt < LlmPrompt
-
# Default prompt template for job extraction
-
#
-
# @return [String] Default prompt template
-
def self.default_prompt_template
-
<<~PROMPT
-
Extract the following information from this job listing HTML and return it as JSON:
-
-
Required fields:
-
- title: Job title
-
- company: Company name (the organization posting the job)
-
- company_domain: The industry/domain the company operates in (e.g., "FinTech", "SaaS", "Healthcare", "E-commerce", "EdTech", "AI/ML", "Cybersecurity", "Gaming", "Social Media", "Enterprise Software", "B2B", "B2C", "Marketplace", "Media/Entertainment", "Real Estate", "Travel", "Logistics", "Automotive", "CleanTech", "Biotech", "Other" - use null if unclear)
-
- job_role: Job role/title (can be the same as title or a normalized version)
-
- job_role_department: The department/function this role belongs to (one of: "Engineering", "Product", "Design", "Data Science", "DevOps/SRE", "Sales", "Marketing", "Customer Success", "Finance", "HR/People", "Legal", "Operations", "Executive", "Research", "QA/Testing", "Security", "IT", "Content", "Other")
-
- job_board: The job board where the job listing was found (e.g. "LinkedIn", "Greenhouse", "Lever", "Indeed", "Glassdoor", "Workable", "Jobvite", "ICIMS", "SmartRecruiters", "BambooHR", "AshbyHQ", "Other")
-
- description: Brief summary of the role (1-2 sentences)
-
- description_markdown: Complete job posting formatted as clean Markdown (see format below)
-
- requirements: Array of requirement strings (one item per requirement)
-
- responsibilities: Array of responsibility strings (one item per responsibility)
-
- location: Office location or "Remote"
-
- remote_type: one of "on_site", "hybrid", or "remote"
-
-
Optional fields (use null if not found):
-
- about_company: A concise "About the company" section (mission/product context)
-
- company_culture: Company values/culture section (how they work, principles, DEI, etc.)
-
- salary_min: Minimum salary as number
-
- salary_max: Maximum salary as number
-
- salary_currency: Currency code (e.g., "USD", "EUR")
-
- compensation_text: Human-readable compensation description (e.g., "$120,000 - $150,000 USD per year")
-
- equity_info: Stock options or equity details
-
- benefits: Array of benefit strings
-
- perks: Array of perk strings
-
- interview_process: Description of the interview/hiring process if mentioned
-
- custom_sections: Any additional structured data as a JSON object
-
-
Also provide:
-
- confidence_score: Your confidence in the extraction accuracy (0.0 to 1.0)
-
- notes: Any extraction challenges or uncertainties
-
-
MARKDOWN FORMAT for description_markdown:
-
- Use ## for main sections (e.g., ## About the Role, ## Responsibilities, ## Requirements)
-
- Use ### for subsections
-
- Use - for bullet lists (not *)
-
- Use **text** for bold emphasis
-
- Use *text* for italic emphasis
-
- DO NOT include any HTML tags
-
- Preserve the logical structure of the original posting
-
- Include all content: about company, role description, responsibilities, requirements, benefits, etc.
-
-
Job Listing URL: {{url}}
-
-
HTML Content:
-
{{html_content}}
-
-
Return only valid JSON with no additional commentary.
-
PROMPT
-
end
-
-
def self.default_system_prompt
-
<<~PROMPT
-
You are an expert at extracting structured job listing data from HTML. You are given a job listing URL and the HTML content of the job listing. You need to extract the structured data from the HTML content.
-
-
For description_markdown, produce clean, well-formatted Markdown that preserves the job posting's structure. Use consistent heading levels (## for sections, ### for subsections) and bullet lists (- item) for lists. Never include HTML tags in the markdown output.
-
PROMPT
-
end
-
-
# Returns the expected variables for this prompt type
-
#
-
# @return [Hash] Variable definitions
-
def self.default_variables
-
{
-
"url" => { "required" => true, "description" => "The job listing URL" },
-
"html_content" => { "required" => true, "description" => "The cleaned HTML content" }
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Ai
-
# Prompt template for post-processing job content into structured fields + Markdown.
-
#
-
# Variables:
-
# - {{url}} - The job listing URL
-
# - {{html_content}} - The job posting content (HTML or text)
-
class JobPostprocessPrompt < LlmPrompt
-
def self.default_prompt_template
-
<<~PROMPT
-
Given the job posting content below, extract missing structured information and produce a clean Markdown version suitable for display.
-
-
IMPORTANT:
-
- Return ONLY valid JSON (no code fences).
-
- job_markdown MUST be valid, well-formatted Markdown.
-
- DO NOT include HTML tags (no <strong>, <p>, etc). Use Markdown instead (**bold**, *italic*).
-
- Only extract what is present in the content. If something isn't present, return null/[] accordingly.
-
-
MARKDOWN FORMAT for job_markdown:
-
- Use ## for main sections (e.g., ## About the Role, ## Responsibilities, ## Requirements, ## Benefits)
-
- Use ### for subsections
-
- Use - for bullet lists (not *)
-
- Use **text** for bold emphasis
-
- Use *text* for italic emphasis
-
- Preserve the logical structure of the original posting
-
- Include all content: about company, role description, responsibilities, requirements, benefits, etc.
-
-
Return JSON with this schema:
-
{
-
"job_markdown": String,
-
"compensation_text": String|null,
-
"salary_min": Number|null,
-
"salary_max": Number|null,
-
"salary_currency": String|null,
-
"interview_process": String|null,
-
"responsibilities_bullets": [String],
-
"requirements_bullets": [String],
-
"benefits_bullets": [String],
-
"perks_bullets": [String],
-
"confidence_score": Number
-
}
-
-
Job URL: {{url}}
-
-
Job Content:
-
{{html_content}}
-
PROMPT
-
end
-
-
def self.default_system_prompt
-
<<~PROMPT
-
You are an expert at extracting structured job posting information and producing clean Markdown for display.
-
-
For job_markdown, produce clean, well-formatted Markdown that preserves the job posting's structure. Use consistent heading levels (## for sections, ### for subsections) and bullet lists (- item) for lists. Never include HTML tags in the markdown output.
-
PROMPT
-
end
-
-
def self.default_variables
-
{
-
"url" => { "required" => true, "description" => "The job listing URL" },
-
"html_content" => { "required" => true, "description" => "The job posting content (HTML or text)" }
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module Ai
-
# Model for tracking all LLM API calls with full observability
-
#
-
# Stores detailed information about every LLM call including
-
# full request/response payloads, token usage, costs, and performance metrics.
-
# Supports polymorphic association to any loggable model.
-
#
-
# @example
-
# log = Ai::LlmApiLog.create!(
-
# operation_type: :job_extraction,
-
# loggable: job_listing,
-
# provider: "anthropic",
-
# model: "claude-sonnet-4-20250514",
-
# input_tokens: 5000,
-
# output_tokens: 500,
-
# latency_ms: 2500,
-
# status: :success
-
# )
-
#
-
1
class LlmApiLog < ApplicationRecord
-
1
self.table_name = "llm_api_logs"
-
-
# Provider cost per 1K tokens (in cents)
-
# Updated as of 2024 pricing
-
PROVIDER_COSTS = {
-
1
"anthropic" => {
-
"claude-sonnet-4-20250514" => { input: 0.3, output: 1.5 },
-
"claude-3-5-sonnet-20241022" => { input: 0.3, output: 1.5 },
-
"claude-3-haiku-20240307" => { input: 0.025, output: 0.125 }
-
},
-
"openai" => {
-
"gpt-4o" => { input: 0.5, output: 1.5 },
-
"gpt-4o-mini" => { input: 0.015, output: 0.06 },
-
"gpt-4-turbo" => { input: 1.0, output: 3.0 }
-
},
-
"ollama" => {
-
# Ollama is free (self-hosted)
-
"default" => { input: 0.0, output: 0.0 }
-
}
-
}.freeze
-
-
# Operation types for LLM calls
-
1
OPERATION_TYPES = %w[
-
job_extraction
-
job_postprocess
-
email_extraction
-
resume_extraction
-
interview_prep_match_analysis
-
interview_prep_focus_areas
-
interview_prep_question_framing
-
interview_prep_strength_positioning
-
assistant_chat
-
assistant_tool_call
-
signal_extraction
-
email_facts_extraction
-
interview_round_extraction
-
round_feedback_extraction
-
application_status_extraction
-
round_prep_comprehensive
-
].freeze
-
-
# Status values
-
1
STATUSES = %i[
-
success
-
error
-
timeout
-
rate_limited
-
].freeze
-
-
# Associations
-
1
belongs_to :loggable, polymorphic: true, optional: true
-
1
belongs_to :llm_prompt, class_name: "Ai::LlmPrompt", optional: true
-
-
# Enum for status
-
1
enum :status, {
-
success: 0,
-
error: 1,
-
timeout: 2,
-
rate_limited: 3
-
}, default: :success
-
-
# Validations
-
1
validates :operation_type, presence: true, inclusion: { in: OPERATION_TYPES }
-
1
validates :provider, presence: true
-
1
validates :model, presence: true
-
-
# Scopes
-
1
scope :recent, -> { order(created_at: :desc) }
-
1
scope :by_provider, ->(provider) { where(provider: provider) }
-
1
scope :by_status, ->(status) { where(status: status) }
-
1
scope :by_operation, ->(operation_type) { where(operation_type: operation_type) }
-
1
scope :successful, -> { where(status: :success) }
-
1
scope :failed, -> { where.not(status: :success) }
-
1
scope :with_errors, -> { where(status: [ :error, :timeout, :rate_limited ]) }
-
1
scope :recent_period, ->(days = 7) { where("created_at > ?", days.days.ago) }
-
1
scope :today, -> { where("created_at > ?", Time.current.beginning_of_day) }
-
-
# Scopes for specific operations
-
1
scope :job_extractions, -> { by_operation("job_extraction") }
-
1
scope :email_extractions, -> { by_operation("email_extraction") }
-
1
scope :resume_extractions, -> { by_operation("resume_extraction") }
-
-
# Callbacks
-
1
before_save :calculate_total_tokens
-
1
before_save :calculate_estimated_cost
-
-
# Returns formatted cost string
-
#
-
# @return [String] Formatted cost (e.g., "$0.0015")
-
1
def formatted_cost
-
then: 0
else: 0
return "N/A" if estimated_cost_cents.nil?
-
then: 0
else: 0
return "Free" if estimated_cost_cents.zero?
-
-
dollars = estimated_cost_cents / 100.0
-
then: 0
if dollars < 0.01
-
format("$%.4f", dollars)
-
else: 0
else
-
format("$%.2f", dollars)
-
end
-
end
-
-
# Returns formatted latency string
-
#
-
# @return [String] Formatted latency (e.g., "2.5s")
-
1
def formatted_latency
-
then: 0
else: 0
return "N/A" if latency_ms.nil?
-
-
then: 0
if latency_ms < 1000
-
"#{latency_ms}ms"
-
else: 0
else
-
"#{(latency_ms / 1000.0).round(2)}s"
-
end
-
end
-
-
# Returns formatted token usage
-
#
-
# @return [String] Formatted tokens (e.g., "5,000 in / 500 out")
-
1
def formatted_tokens
-
parts = []
-
then: 0
else: 0
parts << "#{number_with_delimiter(input_tokens)} in" if input_tokens
-
then: 0
else: 0
parts << "#{number_with_delimiter(output_tokens)} out" if output_tokens
-
then: 0
else: 0
parts.any? ? parts.join(" / ") : "N/A"
-
end
-
-
# Returns the prompt text from request payload
-
#
-
# @return [String, nil] The prompt text or nil
-
1
def prompt_text
-
then: 0
else: 0
then: 0
else: 0
request_payload&.dig("prompt") || request_payload&.dig("messages", 0, "content")
-
end
-
-
# Returns the response text from response payload
-
#
-
# @return [String, nil] The response text or nil
-
1
def response_text
-
then: 0
else: 0
then: 0
else: 0
response_payload&.dig("content") || response_payload&.dig("text")
-
end
-
-
# Returns the list of successfully extracted field names
-
#
-
# @return [Array<String>] Field names
-
1
def extracted_field_names
-
Array(extracted_fields).map(&:to_s)
-
end
-
-
# Returns status badge color for UI
-
#
-
# @return [String] Color name
-
1
def status_badge_color
-
when: 0
case status.to_sym
-
when: 0
when :success then "success"
-
when: 0
when :error then "danger"
-
when: 0
when :timeout then "warning"
-
else: 0
when :rate_limited then "info"
-
else "neutral"
-
end
-
end
-
-
# Returns operation type badge color for UI
-
#
-
# @return [String] Color name
-
1
def operation_badge_color
-
when: 0
case operation_type
-
when: 0
when "job_extraction" then "blue"
-
when: 0
when "job_postprocess" then "blue"
-
when: 0
when "email_extraction" then "purple"
-
else: 0
when "resume_extraction" then "green"
-
else "gray"
-
end
-
end
-
-
# Returns human-readable operation type
-
#
-
# @return [String] Operation name
-
1
def operation_type_name
-
operation_type.humanize.titleize
-
end
-
-
# Class methods for aggregations
-
1
class << self
-
# Calculates total cost for a period
-
#
-
# @param days [Integer] Number of days
-
# @return [Float] Total cost in dollars
-
1
def total_cost_for_period(days = 7)
-
recent_period(days).sum(:estimated_cost_cents).to_f / 100.0
-
end
-
-
# Calculates average latency for a period
-
#
-
# @param days [Integer] Number of days
-
# @return [Float] Average latency in ms
-
1
def average_latency_for_period(days = 7)
-
recent_period(days).where.not(latency_ms: nil).average(:latency_ms).to_f.round(0)
-
end
-
-
# Calculates success rate for a period
-
#
-
# @param days [Integer] Number of days
-
# @return [Float] Success rate percentage
-
1
def success_rate_for_period(days = 7)
-
logs = recent_period(days)
-
then: 0
else: 0
return 0.0 if logs.count.zero?
-
-
(logs.successful.count.to_f / logs.count * 100).round(1)
-
end
-
-
# Returns token usage breakdown by provider
-
#
-
# @param days [Integer] Number of days
-
# @return [Array<Hash>] Usage by provider
-
1
def token_usage_by_provider(days = 7)
-
recent_period(days)
-
.group(:provider)
-
.select("provider, SUM(input_tokens) as total_input, SUM(output_tokens) as total_output, SUM(total_tokens) as total")
-
.map do |result|
-
{
-
provider: result.provider,
-
input_tokens: result.total_input.to_i,
-
output_tokens: result.total_output.to_i,
-
total_tokens: result.total.to_i
-
}
-
end
-
end
-
-
# Returns cost breakdown by provider
-
#
-
# @param days [Integer] Number of days
-
# @return [Hash] Cost by provider
-
1
def cost_by_provider(days = 7)
-
recent_period(days)
-
.group(:provider)
-
.sum(:estimated_cost_cents)
-
.transform_values { |v| (v.to_f / 100.0).round(4) }
-
end
-
-
# Returns cost breakdown by operation type
-
#
-
# @param days [Integer] Number of days
-
# @return [Hash] Cost by operation
-
1
def cost_by_operation(days = 7)
-
recent_period(days)
-
.group(:operation_type)
-
.sum(:estimated_cost_cents)
-
.transform_values { |v| (v.to_f / 100.0).round(4) }
-
end
-
-
# Returns error breakdown by type
-
#
-
# @param days [Integer] Number of days
-
# @return [Hash] Error counts by type
-
1
def error_breakdown(days = 7)
-
recent_period(days)
-
.with_errors
-
.group(:status, :error_type)
-
.count
-
end
-
-
# Returns counts by operation type
-
#
-
# @param days [Integer] Number of days
-
# @return [Hash] Counts by operation
-
1
def counts_by_operation(days = 7)
-
recent_period(days)
-
.group(:operation_type)
-
.count
-
end
-
end
-
-
1
private
-
-
# Calculates total tokens from input and output
-
1
def calculate_total_tokens
-
self.total_tokens = (input_tokens || 0) + (output_tokens || 0)
-
end
-
-
# Calculates estimated cost based on provider pricing
-
1
def calculate_estimated_cost
-
then: 0
else: 0
return if provider.blank? || model.blank?
-
-
provider_pricing = PROVIDER_COSTS.dig(provider.downcase)
-
else: 0
then: 0
return unless provider_pricing
-
-
# Try exact model match first, then default
-
model_pricing = provider_pricing[model] || provider_pricing["default"]
-
else: 0
then: 0
return unless model_pricing
-
-
input_cost = ((input_tokens || 0) / 1000.0) * model_pricing[:input]
-
output_cost = ((output_tokens || 0) / 1000.0) * model_pricing[:output]
-
-
self.estimated_cost_cents = ((input_cost + output_cost) * 100).round
-
end
-
-
# Helper for number formatting
-
1
def number_with_delimiter(number)
-
then: 0
else: 0
return "0" if number.nil?
-
-
number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Ai
-
# Base class for LLM prompts using STI (Single Table Inheritance)
-
#
-
# Provides common functionality for all prompt types with support for:
-
# - Version management
-
# - Active/inactive status with only one active per type
-
# - Variable substitution in templates
-
#
-
# @example
-
# prompt = Ai::JobExtractionPrompt.active_prompt
-
# final_prompt = prompt.build_prompt(url: "https://...", html_content: "...")
-
#
-
class LlmPrompt < ApplicationRecord
-
self.table_name = "llm_prompts"
-
-
# Associations
-
has_many :llm_api_logs, class_name: "Ai::LlmApiLog", dependent: :nullify
-
-
# Validations
-
validates :name, presence: true
-
validates :prompt_template, presence: true
-
validates :version, numericality: { only_integer: true, greater_than: 0 }
-
validates :type, presence: true
-
-
# Scopes
-
scope :active_prompts, -> { where(active: true) }
-
scope :inactive_prompts, -> { where(active: false) }
-
scope :by_version_desc, -> { order(version: :desc) }
-
scope :by_name, -> { order(:name) }
-
-
# Callbacks
-
before_save :deactivate_others_of_same_type, if: -> { active? && active_changed? }
-
-
# Returns the currently active prompt for this type
-
#
-
# @return [Ai::LlmPrompt, nil] Active prompt or nil
-
def self.active_prompt
-
active_prompts.by_version_desc.first
-
end
-
-
# Returns the default prompt template for this type
-
# Subclasses should override this method
-
#
-
# @return [String] Default prompt template
-
def self.default_prompt_template
-
raise NotImplementedError, "Subclasses must implement default_prompt_template"
-
end
-
-
# Returns the default prompt, either from DB or fallback
-
#
-
# @return [String] Prompt template
-
def self.default_prompt
-
active_prompt&.prompt_template || default_prompt_template
-
end
-
-
# Builds a prompt with variables substituted
-
#
-
# @param variables [Hash] Variables to substitute (e.g., url:, html_content:)
-
# @return [String] Final prompt with variables replaced
-
def build_prompt(variables = {})
-
template = prompt_template.dup
-
-
variables.each do |key, value|
-
template.gsub!("{{#{key}}}", value.to_s)
-
end
-
-
template
-
end
-
-
# Returns placeholder variables used in template
-
#
-
# @return [Array<String>] Variable names found in template
-
def template_variables
-
prompt_template.scan(/\{\{(\w+)\}\}/).flatten.uniq
-
end
-
-
# Checks if all required variables are defined
-
#
-
# @return [Boolean] True if all variables are defined
-
def variables_complete?
-
return true if variables.blank?
-
-
required_vars = variables.select { |_, v| v["required"] == true }.keys
-
template_vars = template_variables
-
-
required_vars.all? { |v| template_vars.include?(v) }
-
end
-
-
# Returns human-readable prompt type name
-
#
-
# @return [String] Type name
-
def prompt_type_name
-
self.class.name.demodulize.underscore.humanize.titleize
-
end
-
-
# Duplicates the prompt with incremented version
-
#
-
# @return [Ai::LlmPrompt] New prompt instance (not saved)
-
def duplicate
-
dup.tap do |new_prompt|
-
new_prompt.name = "#{name} (Copy)"
-
new_prompt.active = false
-
new_prompt.version = version + 1
-
end
-
end
-
-
private
-
-
# Deactivates all other prompts of the same STI type
-
def deactivate_others_of_same_type
-
self.class.where.not(id: id).update_all(active: false)
-
end
-
end
-
end
-
-
-
-
-
# frozen_string_literal: true
-
-
module Ai
-
# Prompt template for extracting skills from resume/CV text
-
#
-
# Used by Resumes::AiSkillExtractorService to extract structured skill data
-
# from parsed resume text.
-
#
-
# Variables:
-
# - {{resume_text}} - The parsed resume text content
-
#
-
class ResumeSkillExtractionPrompt < LlmPrompt
-
# Default prompt template for resume skill extraction
-
#
-
# @return [String] Default prompt template
-
def self.default_prompt_template
-
<<~PROMPT
-
Analyze the following resume/Curriculum Vitae text and extract all professional skills, competencies, work history, and areas of expertise.
-
-
For each skill identified, provide:
-
1. **name**: The skill name (use common industry terminology, e.g., "Ruby on Rails" not "RoR")
-
2. **category**: One of: Backend, Frontend, Fullstack, Infrastructure, DevOps, Data, Mobile, Leadership, Communication, ProjectManagement, Design, Security, AI/ML, Other
-
3. **proficiency**: A level from 1-5 based on evidence in the resume:
-
- 5 = Expert (extensive experience, leadership, teaching others)
-
- 4 = Advanced (significant professional experience, complex projects)
-
- 3 = Intermediate (solid working knowledge, multiple projects)
-
- 2 = Elementary (some experience, basic projects)
-
- 1 = Beginner (mentioned but limited evidence)
-
4. **confidence**: Your confidence in this assessment (0.0-1.0)
-
5. **evidence**: A brief quote or description from the resume supporting this skill
-
6. **years**: Estimated years of experience if determinable (null if unclear)
-
-
Also extract work history:
-
- For each job/position, provide: **company** (full company name), **company_domain** (the industry/domain the company operates in, e.g., "FinTech", "SaaS", "Healthcare", "E-commerce", "EdTech", "AI/ML", "Cybersecurity", "Enterprise Software", "B2B", "B2C", etc. - use null if unclear), **role** (job title), **role_department** (the department/function this role belongs to, one of: "Engineering", "Product", "Design", "Data Science", "DevOps/SRE", "Sales", "Marketing", "Customer Success", "Finance", "HR/People", "Legal", "Operations", "Executive", "Research", "QA/Testing", "Security", "IT", "Content", "Other"), **duration** (years or months), **highlight** (a brief description of the job's most significant accomplishment), **start_date** (the date the job started), **end_date** (the date the job ended or null if still current), **current** (true if the job is still current, false if it has ended), **responsibilities** (an array of responsibilities the candidate had in the job), **skills_used** (an array of skills the candidate used in the job)
-
- For each skill used, provide: **name** (the skill name), **confidence** (your confidence in this assessment of the skill's usage, 0.0-1.0), **evidence** (a brief quote or description from the resume supporting this skill usage)
-
- Order from most recent to oldest
-
-
Also provide:
-
- A brief **summary** of the candidate's overall profile (2-3 sentences)
-
- An **overall_confidence** score for the entire extraction (0.0-1.0)
-
- **strengths**: Top 3-5 key strengths based on the resume
-
- **domains**: Primary professional domains/industries
-
- **resume_date**: Estimated date when this resume was last updated (YYYY-MM-DD format, or null if unknown). Look for document dates, "updated" mentions, or infer from the most recent job end date.
-
- **resume_date_confidence**: How confident you are in the resume date ("high", "medium", "low", or "unknown")
-
- **resume_date_source**: How you determined the date ("document_metadata", "explicit_mention", "most_recent_job", or "unknown")
-
-
Respond with valid JSON only, no markdown or explanation:
-
-
{
-
"skills": [
-
{
-
"name": "Ruby on Rails",
-
"category": "Backend",
-
"proficiency": 4,
-
"confidence": 0.9,
-
"evidence": "5+ years building Rails applications at scale",
-
"years": 5
-
}
-
],
-
"work_history": [
-
{
-
"company": "Acme Corp",
-
"company_domain": "FinTech",
-
"role": "Senior Software Engineer",
-
"role_department": "Engineering",
-
"duration": "3 years",
-
"highlight": "Built scalable backend infrastructure for a fintech startup",
-
"start_date": "2021-01-01",
-
"end_date": "2024-01-01",
-
"current": false,
-
"responsibilities": [
-
"Developed and maintained scalable backend infrastructure",
-
"Built RESTful APIs for internal tools and external integrations",
-
"Optimized database queries and implemented caching strategies",
-
"Collaborated with frontend and mobile teams to ensure seamless integration"
-
],
-
"skills_used": [
-
{
-
"name": "Ruby on Rails",
-
"confidence": 0.9,
-
"evidence": "5+ years building Rails applications at scale"
-
}
-
]
-
}
-
],
-
"summary": "Senior backend engineer with strong Ruby and distributed systems experience...",
-
"overall_confidence": 0.85,
-
"strengths": ["Backend Development", "System Design", "Team Leadership"],
-
"domains": ["FinTech", "SaaS"],
-
"resume_date": "2024-06-15",
-
"resume_date_confidence": "medium",
-
"resume_date_source": "most_recent_job"
-
}
-
-
RESUME TEXT:
-
{{resume_text}}
-
PROMPT
-
end
-
-
def self.default_system_prompt
-
<<~PROMPT
-
You are an expert at extracting skills, strengths, domains, competencies, work history, and areas of expertise from unstructured text extracted from a resume or Curriculum Vitae, then converting it into structured json response.
-
Your goal is to return only valid JSON. Do not guess missing values; use null.
-
Do not include markdown or extra commentary.
-
Do not include any other text or formatting.
-
PROMPT
-
end
-
-
# Returns the expected variables for this prompt type
-
#
-
# @return [Hash] Variable definitions
-
def self.default_variables
-
{
-
"resume_text" => { "required" => true, "description" => "The parsed resume text content" }
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Ai
-
# Prompt template for extracting interview round feedback from emails
-
#
-
# Used by Signals::RoundFeedbackProcessor to extract pass/fail results
-
# and detailed feedback from per-round feedback emails.
-
#
-
# Variables:
-
# - {{subject}} - The email subject line
-
# - {{body}} - The email body content
-
# - {{from_email}} - The sender's email address
-
# - {{from_name}} - The sender's display name
-
# - {{company_name}} - The company name if known
-
# - {{recent_rounds}} - JSON array of recent interview rounds for context
-
#
-
class RoundFeedbackExtractionPrompt < LlmPrompt
-
# Default prompt template for round feedback extraction
-
#
-
# @return [String] Default prompt template
-
def self.default_prompt_template
-
<<~PROMPT
-
Analyze the following email to extract interview round feedback/results.
-
-
FROM: {{from_name}} <{{from_email}}>
-
SUBJECT: {{subject}}
-
COMPANY: {{company_name}}
-
-
RECENT INTERVIEW ROUNDS (for context):
-
{{recent_rounds}}
-
-
EMAIL CONTENT:
-
{{body}}
-
-
Extract the following information and respond with a JSON object:
-
-
{
-
"result": "passed|failed|waitlisted|unknown",
-
"round_context": {
-
"stage_mentioned": "The stage/round name mentioned (e.g., 'technical round', 'phone screen', 'final interview')",
-
"interviewer_mentioned": "Name of interviewer mentioned in feedback",
-
"date_mentioned": "Date of the interview being discussed, if mentioned"
-
},
-
"feedback": {
-
"has_detailed_feedback": true/false,
-
"summary": "Brief summary of the feedback",
-
"strengths": ["Array of things that went well"],
-
"improvements": ["Array of areas to improve"],
-
"full_feedback_text": "Complete feedback text if provided"
-
},
-
"next_steps": {
-
"has_next_round": true/false,
-
"next_round_type": "Type of next round (e.g., 'technical', 'hiring manager', 'onsite')",
-
"next_round_hint": "Any hints about what the next round involves",
-
"timeline_hint": "Any mention of timeline (e.g., 'next week', 'within a few days')"
-
},
-
"is_final_round_result": true/false,
-
"sentiment": "positive|negative|neutral",
-
"confidence_score": 0.0 to 1.0
-
}
-
-
Guidelines for result detection:
-
- "passed" - Clear indication of moving forward: "congratulations", "pleased to inform", "moving to next round", "passed"
-
- "failed" - Clear rejection for this round: "not moving forward", "decided not to proceed", "unfortunately"
-
- "waitlisted" - On hold: "waitlist", "keep you in mind", "position on hold"
-
- "unknown" - Cannot determine outcome from email content
-
-
Guidelines for round matching:
-
- Look for stage mentions like "technical interview", "phone screen", "hiring manager round"
-
- Look for interviewer names mentioned in feedback
-
- Look for date references to match with recent rounds
-
-
Guidelines for feedback extraction:
-
- Capture specific strengths mentioned (technical skills, communication, etc.)
-
- Capture specific areas for improvement
-
- If detailed feedback is provided, include the full text
-
-
Guidelines for next steps:
-
- Detect if there's a next round scheduled or mentioned
-
- Identify what type of round comes next
-
- Note any timeline information
-
-
Use null for any field where information is not clearly available.
-
Respond ONLY with the JSON object, no additional text or markdown.
-
PROMPT
-
end
-
-
# Default system prompt for round feedback extraction
-
#
-
# @return [String]
-
def self.default_system_prompt
-
<<~PROMPT
-
You are an expert at analyzing interview feedback emails.
-
Your goal is to:
-
- Determine if the candidate passed, failed, or is waitlisted for this round
-
- Extract any specific feedback provided
-
- Identify what the next steps are
-
- Match the feedback to a specific interview round if possible
-
-
Rules:
-
- Return ONLY valid JSON, no markdown or commentary
-
- Use null for missing information, never guess
-
- Be careful to distinguish between per-round rejection and full application rejection
-
- "Passed" means moving to next round, not necessarily getting the job
-
PROMPT
-
end
-
-
# Returns the expected variables for this prompt type
-
#
-
# @return [Hash] Variable definitions
-
def self.default_variables
-
{
-
"subject" => { "required" => true, "description" => "The email subject line" },
-
"body" => { "required" => true, "description" => "The email body content" },
-
"from_email" => { "required" => true, "description" => "The sender's email address" },
-
"from_name" => { "required" => false, "description" => "The sender's display name" },
-
"company_name" => { "required" => false, "description" => "The company name if known" },
-
"recent_rounds" => { "required" => false, "description" => "JSON array of recent interview rounds" }
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Ai
-
# Prompt template for generating round-specific interview preparation
-
#
-
# Used by InterviewRoundPrep::GenerateService to generate tailored prep content
-
# for specific interview rounds based on round type, company patterns, and user history.
-
#
-
# Variables:
-
# - {{round_context}} - JSON with round details (type, stage, duration, interviewer)
-
# - {{job_context}} - JSON with job/company information
-
# - {{candidate_profile}} - JSON with candidate background and skills
-
# - {{historical_performance}} - JSON with user's performance on similar rounds
-
# - {{company_patterns}} - JSON with company-specific interview patterns
-
#
-
class RoundPrepPrompt < LlmPrompt
-
# Default prompt template for round prep generation
-
#
-
# @return [String] Default prompt template
-
def self.default_prompt_template
-
<<~PROMPT
-
Generate focused interview preparation for a specific interview round.
-
-
INTERVIEW ROUND:
-
{{round_context}}
-
-
JOB CONTEXT:
-
{{job_context}}
-
-
CANDIDATE PROFILE:
-
{{candidate_profile}}
-
-
CANDIDATE'S HISTORICAL PERFORMANCE ON SIMILAR ROUNDS:
-
{{historical_performance}}
-
-
COMPANY INTERVIEW PATTERNS:
-
{{company_patterns}}
-
-
Generate a comprehensive prep guide as a JSON object with this structure:
-
-
{
-
"round_summary": {
-
"type": "The round type slug (e.g., 'coding', 'system_design', 'behavioral')",
-
"type_name": "Human-readable round type name",
-
"company": "Company name",
-
"typical_duration": "Expected duration (e.g., '45-60 min')",
-
"format_hints": ["Array of format hints based on round type and company patterns"]
-
},
-
"expected_questions": [
-
{
-
"category": "Question category or theme",
-
"example": "Example question or topic",
-
"your_preparation": "Personalized prep advice based on candidate's background",
-
"difficulty": "easy/medium/hard"
-
}
-
],
-
"your_history": {
-
"same_type_rounds": "Number of similar rounds completed",
-
"pass_rate": "Pass rate percentage or null",
-
"strengths": ["Identified strengths from historical performance"],
-
"areas_to_watch": ["Areas that need attention based on past feedback"]
-
},
-
"company_patterns": {
-
"typical_focus": ["Areas this company typically focuses on"],
-
"interview_style": "Description of interview style",
-
"success_factors": ["Factors that correlate with success at this company"]
-
},
-
"answer_strategies": [
-
{
-
"strategy": "Strategy name",
-
"description": "How to apply this strategy",
-
"example_application": "Concrete example for this interview"
-
}
-
],
-
"preparation_checklist": [
-
"Specific, actionable preparation items for this round"
-
],
-
"tips": [
-
"Quick tips specific to this round type and company"
-
]
-
}
-
-
Guidelines:
-
- Tailor everything to the specific round type and candidate's background
-
- Reference the candidate's actual skills and experience where relevant
-
- Use historical performance data to personalize strengths and areas to watch
-
- Incorporate company-specific patterns into format hints and focus areas
-
- Keep preparation checklist items specific and actionable
-
- Provide 3-5 expected questions with personalized prep advice
-
- Provide 2-3 answer strategies relevant to this round type
-
- Keep tips concise and immediately actionable (5-7 tips max)
-
- If historical data is limited, focus on general best practices for the round type
-
-
Respond ONLY with the JSON object, no additional text or markdown.
-
PROMPT
-
end
-
-
# Default system prompt for round prep generation
-
#
-
# @return [String]
-
def self.default_system_prompt
-
<<~PROMPT
-
You are an expert interview coach helping candidates prepare for specific interview rounds.
-
Your goal is to provide personalized, actionable preparation guidance that:
-
-
- Leverages the candidate's specific background and experience
-
- Addresses their known strengths and areas for improvement
-
- Incorporates patterns specific to the target company
-
- Provides concrete, actionable preparation steps
-
- Is tailored to the specific round type (coding, system design, behavioral, etc.)
-
-
Rules:
-
- Return ONLY valid JSON, no markdown or commentary
-
- Be specific and personalized - avoid generic advice
-
- Focus on what the candidate can do in the time before the interview
-
- Reference actual skills and experience from the candidate profile
-
- If company data is limited, extrapolate from general industry patterns
-
- Keep advice practical and confidence-building
-
PROMPT
-
end
-
-
# Returns the expected variables for this prompt type
-
#
-
# @return [Hash] Variable definitions
-
def self.default_variables
-
{
-
"round_context" => {
-
"required" => true,
-
"description" => "JSON with interview round details (type, stage, duration, interviewer)"
-
},
-
"job_context" => {
-
"required" => true,
-
"description" => "JSON with job and company information"
-
},
-
"candidate_profile" => {
-
"required" => true,
-
"description" => "JSON with candidate background, skills, and experience"
-
},
-
"historical_performance" => {
-
"required" => false,
-
"description" => "JSON with candidate's historical performance on similar rounds"
-
},
-
"company_patterns" => {
-
"required" => false,
-
"description" => "JSON with company-specific interview patterns"
-
}
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Ai
-
# Prompt template for extracting actionable signals from interview-related emails
-
#
-
# Used by Signals::ExtractionService to extract structured intelligence
-
# from email content including company info, recruiter details, job information,
-
# relevant links, and suggested actions.
-
#
-
# Variables:
-
# - {{subject}} - The email subject line
-
# - {{body}} - The email body content
-
# - {{from_email}} - The sender's email address
-
# - {{from_name}} - The sender's display name
-
# - {{email_type}} - The classified email type (interview_invite, recruiter_outreach, etc.)
-
#
-
class SignalExtractionPrompt < LlmPrompt
-
# Default prompt template for signal extraction
-
#
-
# @return [String] Default prompt template
-
def self.default_prompt_template
-
<<~PROMPT
-
Analyze the following interview-related email and extract actionable intelligence.
-
The email has been classified as: {{email_type}}
-
-
FROM: {{from_name}} <{{from_email}}>
-
SUBJECT: {{subject}}
-
-
EMAIL CONTENT:
-
{{body}}
-
-
Extract the following information and respond with a JSON object:
-
-
{
-
"company": {
-
"name": "The company with the job opening (extract from signature, domain, or content)",
-
"website": "Company website URL if mentioned or derivable from email domain",
-
"careers_url": "URL to careers/jobs page if found",
-
"domain": "Industry domain (e.g., 'FinTech', 'SaaS', 'Healthcare', 'E-commerce', 'AI/ML', etc.)"
-
},
-
"recruiter": {
-
"name": "Recruiter/sender's full name",
-
"email": "Recruiter's email address (may differ from sender if forwarded)",
-
"title": "Recruiter's job title (e.g., 'Senior Recruiter', 'Talent Acquisition Manager')",
-
"linkedin_url": "Recruiter's LinkedIn profile URL if found"
-
},
-
"job": {
-
"title": "Job title or role being discussed",
-
"department": "Department (Engineering, Product, Design, Data Science, etc.)",
-
"location": "Job location (city, remote, hybrid, etc.)",
-
"url": "Direct URL to the job posting or application page",
-
"salary_hint": "Any mention of compensation, salary range, or benefits"
-
},
-
"action_links": [
-
{
-
"url": "Full URL found in the email",
-
"action_label": "Human-readable action button text (e.g., 'Schedule Interview', 'View Job at Toptal', 'Apply Now', 'Learn About Our Culture')",
-
"priority": 1-5 (1=most important action, 5=least important)
-
}
-
],
-
"suggested_actions": [
-
"Array of backend actions (usually empty - UI handles most actions automatically)",
-
"Only include: start_application (if this is clearly a NEW opportunity worth tracking as an application)"
-
],
-
"key_insights": "Brief summary of important details (tech stack, team size, interview process, timeline, etc.)",
-
"is_forwarded": true/false,
-
"confidence_score": 0.0 to 1.0
-
}
-
-
Guidelines for action_links:
-
- Include only the MOST RELEVANT links
-
- Prefer direct, first-party links (avoid redirect wrappers)
-
- Generate ACTIONABLE button labels that tell the user what clicking will do
-
- Include company name in labels when relevant (e.g., "View Toptal Careers" not just "View Careers")
-
- For scheduling links (calendly, goodtime, etc.), use labels like "Schedule Interview" or "Book Call"
-
- For interview joins, use "Join [Company] Interview" or "Join Zoom Interview"
-
- For job postings, use "View Job Posting" or "Apply for [Role]"
-
- For company pages, use "Learn About [Company]" or "Visit [Company] Website"
-
- Exclude low-value or boilerplate links (unsubscribe, view in browser, privacy, terms, help, forwarding guides, calendar event details, generic Google Calendar links)
-
- Prioritize: 1=scheduling/join/apply, 2=job posting, 3=company info, 4=recruiter profile
-
- Only include links that provide value to the job seeker
-
-
Guidelines for suggested_actions:
-
- start_application: Include ONLY if this is clearly a new opportunity (e.g., recruiter outreach, interview invite)
-
- Most emails don't need suggested_actions - the UI provides matching functionality
-
- Company and recruiter info is saved automatically, no action needed
-
-
Other guidelines:
-
- Extract company name from email signature, domain, or content
-
- If sender email domain is a company domain (not gmail/outlook/etc.), use it to derive company website
-
- Use null for any field where information is not clearly available
-
- confidence_score should reflect overall extraction quality (0.0-1.0)
-
-
Respond ONLY with the JSON object, no additional text or markdown.
-
PROMPT
-
end
-
-
# Default system prompt for signal extraction
-
#
-
# @return [String]
-
def self.default_system_prompt
-
<<~PROMPT
-
You are an expert at extracting actionable intelligence from interview and recruiting emails.
-
Your goal is to identify key information that helps job seekers take action:
-
- Company details for research
-
- Recruiter contact info for follow-up
-
- Job details for application tracking
-
- Scheduling links for booking interviews
-
- Relevant actions the user should take
-
-
Rules:
-
- Return ONLY valid JSON, no markdown or commentary
-
- Use null for missing information, never guess
-
- Extract URLs exactly as they appear, but avoid redirect wrappers when possible
-
- Avoid returning long, exhaustive link lists
-
- Be conservative with confidence scores
-
PROMPT
-
end
-
-
# Returns the expected variables for this prompt type
-
#
-
# @return [Hash] Variable definitions
-
def self.default_variables
-
{
-
"subject" => { "required" => true, "description" => "The email subject line" },
-
"body" => { "required" => true, "description" => "The email body content" },
-
"from_email" => { "required" => true, "description" => "The sender's email address" },
-
"from_name" => { "required" => false, "description" => "The sender's display name" },
-
"email_type" => { "required" => false, "description" => "The classified email type" }
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Ai
-
# Prompt template for extracting application status changes from emails
-
#
-
# Used by Signals::ApplicationStatusProcessor to extract rejection, offer,
-
# and status update information from emails.
-
#
-
# Variables:
-
# - {{subject}} - The email subject line
-
# - {{body}} - The email body content
-
# - {{from_email}} - The sender's email address
-
# - {{from_name}} - The sender's display name
-
# - {{company_name}} - The company name if known
-
# - {{current_status}} - The current application status
-
#
-
class StatusExtractionPrompt < LlmPrompt
-
# Default prompt template for status extraction
-
#
-
# @return [String] Default prompt template
-
def self.default_prompt_template
-
<<~PROMPT
-
Analyze the following email to determine if it indicates a change in application status.
-
-
FROM: {{from_name}} <{{from_email}}>
-
SUBJECT: {{subject}}
-
COMPANY: {{company_name}}
-
CURRENT APPLICATION STATUS: {{current_status}}
-
-
EMAIL CONTENT:
-
{{body}}
-
-
Extract the following information and respond with a JSON object:
-
-
{
-
"status_change": {
-
"type": "rejection|offer|withdrawal|ghosted|on_hold|no_change",
-
"is_final": true/false,
-
"effective_date": "ISO 8601 date if mentioned"
-
},
-
"rejection_details": {
-
"reason": "The stated reason for rejection (position filled, other candidates, not a fit, etc.)",
-
"stage_rejected_at": "Stage where rejection occurred if mentioned (screening, technical, final, etc.)",
-
"is_generic": true/false (true if it's a generic rejection template),
-
"door_open": true/false (true if they mention keeping in touch for future opportunities)
-
},
-
"offer_details": {
-
"role_title": "Job title offered",
-
"department": "Department/team",
-
"start_date": "Proposed start date if mentioned",
-
"response_deadline": "Deadline to respond to offer",
-
"includes_compensation_info": true/false,
-
"compensation_hints": "Any salary/benefits mentioned (do not extract exact numbers)",
-
"next_steps": "What they need from the candidate (sign offer, complete background check, etc.)"
-
},
-
"feedback": {
-
"has_feedback": true/false,
-
"feedback_text": "Any feedback provided about the candidate",
-
"is_constructive": true/false (true if actionable feedback is given)
-
},
-
"follow_up": {
-
"should_follow_up": true/false,
-
"follow_up_date": "Suggested follow-up date if mentioned",
-
"contact_person": "Who to contact for questions",
-
"contact_email": "Email to contact"
-
},
-
"sentiment": "positive|negative|neutral|mixed",
-
"confidence_score": 0.0 to 1.0
-
}
-
-
Guidelines for status_change.type:
-
- "rejection" - Clear indication the application/interview process is ending negatively
-
- "offer" - Explicit job offer being extended
-
- "withdrawal" - Company withdrawing the position/process
-
- "ghosted" - This email indicates extended silence or ghosting
-
- "on_hold" - Position/process is paused but not ended
-
- "no_change" - Email doesn't indicate a status change (follow-up, scheduling, etc.)
-
-
Guidelines for rejection detection:
-
- Look for: "regret to inform", "not moving forward", "decided to go with other candidates"
-
- Check if it's a per-round rejection (fail one round) vs full application rejection
-
- is_generic = true for templated rejections with no personalization
-
- door_open = true if they mention "keep your resume on file" or "future opportunities"
-
-
Guidelines for offer detection:
-
- Must be an actual job offer, not just positive feedback
-
- Look for: "pleased to offer", "extend an offer", "offer letter", "congratulations"
-
- Note any deadlines for responding to the offer
-
-
Use null for any field where information is not clearly available.
-
Respond ONLY with the JSON object, no additional text or markdown.
-
PROMPT
-
end
-
-
# Default system prompt for status extraction
-
#
-
# @return [String]
-
def self.default_system_prompt
-
<<~PROMPT
-
You are an expert at analyzing job application emails to detect status changes.
-
Your goal is to:
-
- Determine if the email indicates a rejection, offer, or other status change
-
- Extract relevant details about the change
-
- Identify any feedback or next steps mentioned
-
- Distinguish between per-round rejection and full application rejection
-
-
Rules:
-
- Return ONLY valid JSON, no markdown or commentary
-
- Use null for missing information, never guess
-
- Be conservative - only mark as rejection/offer if clearly indicated
-
- "Congratulations on moving to the next round" is NOT an offer
-
PROMPT
-
end
-
-
# Returns the expected variables for this prompt type
-
#
-
# @return [Hash] Variable definitions
-
def self.default_variables
-
{
-
"subject" => { "required" => true, "description" => "The email subject line" },
-
"body" => { "required" => true, "description" => "The email body content" },
-
"from_email" => { "required" => true, "description" => "The sender's email address" },
-
"from_name" => { "required" => false, "description" => "The sender's display name" },
-
"company_name" => { "required" => false, "description" => "The company name if known" },
-
"current_status" => { "required" => false, "description" => "The current application status" }
-
}
-
end
-
end
-
end
-
1
class ApplicationRecord < ActiveRecord::Base
-
1
primary_abstract_class
-
end
-
# frozen_string_literal: true
-
-
# ApplicationSkillTag join model connecting interview applications with skill tags
-
class ApplicationSkillTag < ApplicationRecord
-
self.table_name = "interview_skill_tags"
-
-
belongs_to :interview_application, foreign_key: :interview_id
-
belongs_to :skill_tag
-
-
validates :interview_application, :skill_tag, presence: true
-
validates :interview_id, uniqueness: { scope: :skill_tag_id }
-
end
-
-
1
class BaseAasm < AASM::Base
-
1
def log_transitions!
-
3
klass.class_eval do
-
3
aasm with_klass: BaseAasm do
-
3
after_all_transitions :log_transitions
-
end
-
end
-
end
-
-
# A custom annotation that we want available across many AASM models.
-
1
def requires_guards!
-
3
klass.class_eval do
-
3
def log_transitions
-
Transition.create!(event: aasm.current_event, from_state: aasm.from_state, to_state: aasm.to_state, resource: self)
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Billing
-
# Stores the mapping between an internal user and a payment provider's customer record.
-
class Customer < ApplicationRecord
-
self.table_name = "billing_customers"
-
-
PROVIDERS = %w[lemonsqueezy].freeze
-
-
belongs_to :user
-
has_many :orders, class_name: "Billing::Order", foreign_key: :billing_customer_id, dependent: :nullify
-
-
store_accessor :urls, :customer_portal_url, :latest_receipt_url
-
-
validates :uuid, presence: true, uniqueness: true
-
validates :provider, presence: true, inclusion: { in: PROVIDERS }
-
validates :user_id, uniqueness: { scope: :provider }
-
-
before_validation :ensure_uuid, on: :create
-
-
private
-
-
def ensure_uuid
-
self.uuid ||= SecureRandom.uuid
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Billing
-
# A time-bounded entitlement override for a user (e.g., trials, promos, admin grants).
-
#
-
# `entitlements` is a JSON map keyed by feature_key:
-
# { "pattern_detection" => { "enabled" => true }, "ai_summaries" => { "enabled" => true, "limit" => 50 } }
-
class EntitlementGrant < ApplicationRecord
-
self.table_name = "billing_entitlement_grants"
-
-
SOURCES = %w[trial admin promo purchase].freeze
-
-
belongs_to :user
-
belongs_to :plan, class_name: "Billing::Plan", foreign_key: :billing_plan_id, optional: true
-
-
validates :uuid, presence: true, uniqueness: true
-
validates :source, presence: true, inclusion: { in: SOURCES }
-
validates :starts_at, presence: true
-
validates :expires_at, presence: true
-
-
validate :validate_time_window
-
-
before_validation :ensure_uuid, on: :create
-
-
scope :active_at, ->(time) { where("starts_at <= ? AND expires_at > ?", time, time) }
-
-
# @param time [Time]
-
# @return [Boolean]
-
def active?(time: Time.current)
-
starts_at <= time && expires_at > time
-
end
-
-
private
-
-
def ensure_uuid
-
self.uuid ||= SecureRandom.uuid
-
end
-
-
def validate_time_window
-
return if starts_at.blank? || expires_at.blank?
-
-
errors.add(:expires_at, "must be after starts_at") if expires_at <= starts_at
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Billing
-
# A feature flag or quota that can be granted by plans and/or explicit grants.
-
class Feature < ApplicationRecord
-
self.table_name = "billing_features"
-
-
KINDS = %w[boolean quota].freeze
-
-
has_many :plan_entitlements, class_name: "Billing::PlanEntitlement", dependent: :destroy, inverse_of: :feature
-
has_many :plans, through: :plan_entitlements
-
-
validates :uuid, presence: true, uniqueness: true
-
validates :key, presence: true, uniqueness: { case_sensitive: true }
-
validates :name, presence: true
-
validates :kind, presence: true, inclusion: { in: KINDS }
-
-
before_validation :ensure_uuid, on: :create
-
-
after_commit :purge_catalog_cache
-
-
private
-
-
def ensure_uuid
-
self.uuid ||= SecureRandom.uuid
-
end
-
-
def purge_catalog_cache
-
Billing::Catalog.purge_cache!
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Billing
-
# Stores LemonSqueezy order data for receipts and audit.
-
class Order < ApplicationRecord
-
self.table_name = "billing_orders"
-
-
PROVIDERS = %w[lemonsqueezy].freeze
-
-
belongs_to :user
-
belongs_to :customer, class_name: "Billing::Customer", foreign_key: :billing_customer_id, optional: true
-
belongs_to :subscription, class_name: "Billing::Subscription", foreign_key: :billing_subscription_id, optional: true
-
-
validates :uuid, presence: true, uniqueness: true
-
validates :provider, presence: true, inclusion: { in: PROVIDERS }
-
validates :external_order_id, presence: true, uniqueness: { scope: :provider }
-
-
before_validation :ensure_uuid, on: :create
-
-
private
-
-
# Ensures a UUID is assigned before validation.
-
#
-
# @return [void]
-
def ensure_uuid
-
self.uuid ||= SecureRandom.uuid
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Billing
-
# A subscription plan displayed in the app and on public pricing pages.
-
#
-
# Plans are the source-of-truth for pricing/feature entitlements and are managed
-
# through the internal developer portal.
-
class Plan < ApplicationRecord
-
self.table_name = "billing_plans"
-
-
PLAN_TYPES = %w[free recurring one_time].freeze
-
INTERVALS = %w[month year].freeze
-
-
has_many :plan_entitlements, class_name: "Billing::PlanEntitlement", dependent: :destroy, inverse_of: :plan
-
has_many :features, through: :plan_entitlements
-
has_many :provider_mappings, class_name: "Billing::ProviderMapping", dependent: :destroy, inverse_of: :plan
-
-
validates :uuid, presence: true, uniqueness: true
-
validates :key, presence: true, uniqueness: { case_sensitive: true }
-
validates :name, presence: true
-
validates :plan_type, presence: true, inclusion: { in: PLAN_TYPES }
-
validates :currency, presence: true
-
validates :sort_order, numericality: { only_integer: true }
-
-
validate :validate_interval_for_plan_type
-
validate :validate_amount_for_plan_type
-
-
before_validation :ensure_uuid, on: :create
-
before_validation :normalize_metadata_json
-
-
after_commit :purge_catalog_cache
-
-
scope :published, -> { where(published: true) }
-
scope :ordered, -> { order(sort_order: :asc, amount_cents: :asc, name: :asc) }
-
-
# @return [Boolean] Whether this plan is the free tier.
-
def free?
-
plan_type == "free"
-
end
-
-
# @return [Boolean] Whether this plan is a recurring subscription (e.g. monthly).
-
def recurring?
-
plan_type == "recurring"
-
end
-
-
# @return [Boolean] Whether this plan is a one-time purchase (e.g. Sprint pass).
-
def one_time?
-
plan_type == "one_time"
-
end
-
-
private
-
-
def ensure_uuid
-
self.uuid ||= SecureRandom.uuid
-
end
-
-
def normalize_metadata_json
-
return if metadata.blank?
-
return if metadata.is_a?(Hash)
-
-
# The developer portal JSON field can sometimes persist metadata as a JSON string.
-
# If that happens, parse it back into an object so views/controllers don't treat
-
# it like a Ruby String (e.g. `"..."["pricing_features"]` returning `"pricing_features"`).
-
if metadata.is_a?(String)
-
parsed = JSON.parse(metadata) rescue nil
-
self.metadata = parsed if parsed.is_a?(Hash)
-
end
-
end
-
-
def validate_interval_for_plan_type
-
return if interval.blank?
-
-
unless recurring?
-
errors.add(:interval, "must be blank unless plan is recurring")
-
return
-
end
-
-
errors.add(:interval, "must be month or year") unless INTERVALS.include?(interval)
-
end
-
-
def validate_amount_for_plan_type
-
return if free?
-
-
errors.add(:amount_cents, "must be present for paid plans") if amount_cents.blank?
-
errors.add(:amount_cents, "must be >= 0") if amount_cents.present? && amount_cents.negative?
-
end
-
-
def purge_catalog_cache
-
Billing::Catalog.purge_cache!
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Billing
-
# Joins a Plan to a Feature and defines whether it's enabled and/or quota-limited.
-
class PlanEntitlement < ApplicationRecord
-
self.table_name = "billing_plan_entitlements"
-
-
belongs_to :plan, class_name: "Billing::Plan", inverse_of: :plan_entitlements
-
belongs_to :feature, class_name: "Billing::Feature", inverse_of: :plan_entitlements
-
-
validates :plan_id, uniqueness: { scope: :feature_id }
-
-
validate :validate_limit_for_feature_kind
-
-
after_commit :purge_catalog_cache
-
-
private
-
-
def validate_limit_for_feature_kind
-
return if feature.nil?
-
-
# For quota features, a blank limit means "unlimited".
-
# (We still allow setting an explicit numeric cap for Free/trials.)
-
if feature.kind == "quota" && limit.present?
-
errors.add(:limit, "must be >= 0") if limit.to_i.negative?
-
end
-
-
if feature.kind == "boolean" && limit.present?
-
errors.add(:limit, "must be blank for boolean features")
-
end
-
end
-
-
def purge_catalog_cache
-
Billing::Catalog.purge_cache!
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Billing
-
# Maps an internal plan to a payment provider's identifiers (e.g. LemonSqueezy product/variant).
-
class ProviderMapping < ApplicationRecord
-
self.table_name = "billing_provider_mappings"
-
-
PROVIDERS = %w[lemonsqueezy].freeze
-
-
belongs_to :plan, class_name: "Billing::Plan", inverse_of: :provider_mappings
-
-
validates :uuid, presence: true, uniqueness: true
-
validates :provider, presence: true
-
validates :provider, inclusion: { in: PROVIDERS }
-
validates :plan_id, uniqueness: { scope: :provider }
-
-
before_validation :ensure_uuid, on: :create
-
-
after_commit :purge_catalog_cache
-
-
private
-
-
def ensure_uuid
-
self.uuid ||= SecureRandom.uuid
-
end
-
-
def purge_catalog_cache
-
Billing::Catalog.purge_cache!
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Billing
-
# A user's subscription state, synced from the payment provider via webhooks.
-
class Subscription < ApplicationRecord
-
self.table_name = "billing_subscriptions"
-
-
PROVIDERS = %w[lemonsqueezy].freeze
-
STATUSES = %w[active trialing cancelled expired past_due inactive].freeze
-
-
belongs_to :user
-
belongs_to :plan, class_name: "Billing::Plan", optional: true
-
has_many :orders, class_name: "Billing::Order", foreign_key: :billing_subscription_id, dependent: :nullify
-
-
store_accessor :urls,
-
:customer_portal_url,
-
:update_payment_method_url,
-
:update_subscription_url,
-
:latest_invoice_url,
-
:latest_receipt_url
-
-
validates :uuid, presence: true, uniqueness: true
-
validates :provider, presence: true, inclusion: { in: PROVIDERS }
-
validates :status, presence: true, inclusion: { in: STATUSES }
-
-
before_validation :ensure_uuid, on: :create
-
-
scope :active, -> { where(status: %w[active trialing]) }
-
-
# @param at [Time]
-
# @return [Boolean]
-
def active_at?(at: Time.current)
-
return true if status == "active"
-
return true if status == "trialing" && trial_ends_at.present? && trial_ends_at > at
-
-
current_period_ends_at.present? && current_period_ends_at > at
-
end
-
-
private
-
-
def ensure_uuid
-
self.uuid ||= SecureRandom.uuid
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Billing
-
# Tracks usage for a given feature key and time window (for quota enforcement).
-
class UsageCounter < ApplicationRecord
-
self.table_name = "billing_usage_counters"
-
-
belongs_to :user
-
-
validates :uuid, presence: true, uniqueness: true
-
validates :feature_key, presence: true
-
validates :used, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
-
validates :period_starts_at, presence: true
-
validates :period_ends_at, presence: true
-
validates :feature_key, uniqueness: { scope: [ :user_id, :period_starts_at ] }
-
-
validate :validate_period
-
-
before_validation :ensure_uuid, on: :create
-
-
# Increments usage by a delta for the given period, creating the counter if needed.
-
#
-
# @param user [User]
-
# @param feature_key [String]
-
# @param period_starts_at [Time]
-
# @param period_ends_at [Time]
-
# @param delta [Integer]
-
# @return [Billing::UsageCounter]
-
def self.increment!(user:, feature_key:, period_starts_at:, period_ends_at:, delta: 1)
-
counter = find_or_create_by!(user: user, feature_key: feature_key, period_starts_at: period_starts_at) do |c|
-
c.period_ends_at = period_ends_at
-
end
-
-
counter.with_lock do
-
counter.used += delta
-
counter.save!
-
end
-
-
counter
-
end
-
-
private
-
-
def ensure_uuid
-
self.uuid ||= SecureRandom.uuid
-
end
-
-
def validate_period
-
return if period_starts_at.blank? || period_ends_at.blank?
-
-
errors.add(:period_ends_at, "must be after period_starts_at") if period_ends_at <= period_starts_at
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Billing
-
# Stores raw webhook events from payment providers for idempotency and replay.
-
class WebhookEvent < ApplicationRecord
-
self.table_name = "billing_webhook_events"
-
-
PROVIDERS = %w[lemonsqueezy].freeze
-
STATUSES = %w[pending processed failed ignored].freeze
-
-
validates :uuid, presence: true, uniqueness: true
-
validates :provider, presence: true, inclusion: { in: PROVIDERS }
-
validates :idempotency_key, presence: true, uniqueness: { scope: :provider }
-
validates :status, presence: true, inclusion: { in: STATUSES }
-
validates :received_at, presence: true
-
-
before_validation :ensure_uuid, on: :create
-
-
scope :pending, -> { where(status: "pending") }
-
-
private
-
-
def ensure_uuid
-
self.uuid ||= SecureRandom.uuid
-
end
-
end
-
end
-
-
-
# frozen_string_literal: true
-
-
# BlogPost represents a public-facing blog article managed via the custom admin panel.
-
#
-
# Content is stored as markdown-like text and rendered on the public blog pages.
-
class BlogPost < ApplicationRecord
-
extend FriendlyId
-
-
STATUSES = %i[draft published].freeze
-
-
acts_as_taggable_on :tags
-
-
# Determine public storage service based on environment
-
#
-
# @return [Symbol] The storage service to use for public assets
-
def self.public_storage_service
-
case Rails.env
-
when "production" then :cloudflare_public
-
when "test" then :test_public
-
else :local_public
-
end
-
end
-
-
has_one_attached :cover_image, service: public_storage_service
-
-
friendly_id :title, use: [ :slugged, :finders ]
-
-
enum :status, STATUSES, default: :draft
-
-
validates :title, presence: true
-
validates :slug, presence: true, uniqueness: true
-
validates :body, presence: true
-
-
scope :published_publicly, -> { published.where.not(published_at: nil).where("published_at <= ?", Time.current) }
-
scope :recent_first, -> { order(published_at: :desc, created_at: :desc) }
-
-
# Generate a new slug when the title changes or when slug is blank.
-
#
-
# @return [Boolean]
-
def should_generate_new_friendly_id?
-
slug.blank? || will_save_change_to_title?
-
end
-
-
# Returns true when this post should be visible publicly.
-
#
-
# @return [Boolean]
-
def publicly_visible?
-
published? && published_at.present? && published_at <= Time.current
-
end
-
-
# Returns an optimized variant for the cover image.
-
# Uses resize_to_limit to scale without cropping - preserves entire image.
-
#
-
# @param size [Symbol] :thumbnail, :medium, :large, :og
-
# @return [ActiveStorage::Variant, ActiveStorage::Attached, nil]
-
def cover_image_variant(size: :medium)
-
return unless cover_image.attached?
-
-
dimensions =
-
case size
-
when :thumbnail then [ 600, 400 ]
-
when :medium then [ 1200, 800 ]
-
when :large then [ 1600, 1067 ]
-
when :og then [ 1200, 630 ] # OpenGraph standard - this one uses fill for social
-
else
-
nil
-
end
-
-
return cover_image if dimensions.nil?
-
-
# OG images need exact dimensions for social sharing, others preserve aspect ratio
-
if size == :og
-
cover_image.variant(resize_to_fill: dimensions)
-
else
-
cover_image.variant(resize_to_limit: dimensions)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# Category model used to group JobRoles and SkillTags with dedup-friendly semantics.
-
class Category < ApplicationRecord
-
include Disableable
-
-
enum :kind, { job_role: 0, skill_tag: 1 }
-
-
has_many :job_roles, dependent: :nullify
-
has_many :skill_tags, dependent: :nullify
-
has_many :interview_round_types, dependent: :nullify
-
-
validates :name, presence: true
-
validates :kind, presence: true
-
-
normalizes :name, with: ->(name) { name.to_s.strip }
-
-
scope :alphabetical, -> { order(:name) }
-
scope :for_kind, ->(kind) { where(kind: kind) }
-
scope :departments, -> { for_kind(:job_role).alphabetical }
-
scope :skill_categories, -> { for_kind(:skill_tag).alphabetical }
-
-
# Returns display name for the category
-
# @return [String]
-
def display_name
-
name
-
end
-
-
# Alias for job_role categories (departments)
-
# @return [Boolean]
-
def department?
-
job_role?
-
end
-
-
# Merges a source category into a target category
-
#
-
# @param source [Category] The category to be merged (will be deleted)
-
# @param target [Category] The category to merge into
-
# @return [Hash] Result hash with :success, :message/:error keys
-
def self.merge_categories(source, target)
-
if source == target
-
return { success: false, error: "Cannot merge a category into itself." }
-
end
-
-
if source.nil? || target.nil?
-
return { success: false, error: "Source or target category not found." }
-
end
-
-
if source.kind != target.kind
-
return { success: false, error: "Cannot merge categories of different kinds (#{source.kind} vs #{target.kind})." }
-
end
-
-
stats = { job_roles: 0, skill_tags: 0 }
-
-
transaction do
-
# Transfer job_roles
-
stats[:job_roles] = JobRole.where(category: source).update_all(category_id: target.id)
-
-
# Transfer skill_tags
-
stats[:skill_tags] = SkillTag.where(category: source).update_all(category_id: target.id)
-
-
# Delete the source category
-
source.destroy!
-
end
-
-
{
-
success: true,
-
message: "Transferred #{stats[:job_roles]} job roles and #{stats[:skill_tags]} skill tags."
-
}
-
rescue ActiveRecord::RecordNotDestroyed => e
-
Rails.logger.error("Category merge failed - could not delete source: #{e.message}")
-
{ success: false, error: "Merge failed: Could not delete the source category. #{e.record.errors.full_messages.join(', ')}" }
-
rescue => e
-
Rails.logger.error("Category merge failed: #{e.class} - #{e.message}")
-
{ success: false, error: "Merge failed: #{e.message}" }
-
end
-
end
-
# frozen_string_literal: true
-
-
# Company model representing companies users apply to
-
class Company < ApplicationRecord
-
include Disableable
-
-
has_many :job_listings, dependent: :destroy
-
has_many :interview_applications, dependent: :nullify
-
has_many :users_with_current_company, class_name: "User", foreign_key: "current_company_id", dependent: :nullify
-
has_many :user_target_companies, dependent: :destroy
-
has_many :users_targeting, through: :user_target_companies, source: :user
-
has_many :email_senders, dependent: :nullify
-
has_many :auto_detected_email_senders, class_name: "EmailSender", foreign_key: "auto_detected_company_id", dependent: :nullify
-
-
validates :name, presence: true, uniqueness: true
-
-
normalizes :name, with: ->(name) { name.strip }
-
normalizes :website, with: ->(website) { website&.strip }
-
-
scope :alphabetical, -> { order(:name) }
-
scope :with_logo, -> { where.not(logo_url: nil) }
-
-
# Returns a display name for the company
-
# @return [String] Company name
-
def display_name
-
name
-
end
-
-
# Checks if company has a logo
-
# @return [Boolean] True if logo exists
-
def has_logo?
-
logo_url.present?
-
end
-
-
# Merges a source company into a target company
-
#
-
# @param source [Company] The company to be merged (will be deleted)
-
# @param target [Company] The company to merge into
-
# @return [Hash] Result hash with :success, :message/:error keys
-
def self.merge_companies(source, target)
-
if source == target
-
return { success: false, error: "Cannot merge a company into itself." }
-
end
-
-
if source.nil? || target.nil?
-
return { success: false, error: "Source or target company not found." }
-
end
-
-
stats = {
-
job_listings: 0,
-
interview_applications: 0,
-
users_current: 0,
-
user_targets: 0,
-
email_senders: 0
-
}
-
-
transaction do
-
# Transfer job_listings
-
stats[:job_listings] = JobListing.where(company: source).update_all(company_id: target.id)
-
-
# Transfer interview_applications
-
stats[:interview_applications] = InterviewApplication.where(company: source).update_all(company_id: target.id)
-
-
# Transfer users with current_company
-
stats[:users_current] = User.where(current_company_id: source.id).update_all(current_company_id: target.id)
-
-
# Handle duplicate user_target_companies
-
duplicate_target_ids = UserTargetCompany.where(company: source)
-
.joins("INNER JOIN user_target_companies utc2 ON user_target_companies.user_id = utc2.user_id")
-
.where("utc2.company_id = ?", target.id)
-
.pluck(:id)
-
UserTargetCompany.where(id: duplicate_target_ids).delete_all
-
-
# Transfer remaining user_target_companies
-
stats[:user_targets] = UserTargetCompany.where(company: source).update_all(company_id: target.id)
-
-
# Transfer email_senders
-
stats[:email_senders] = EmailSender.where(company: source).update_all(company_id: target.id)
-
EmailSender.where(auto_detected_company_id: source.id).update_all(auto_detected_company_id: target.id)
-
-
# Delete the source company
-
source.destroy!
-
end
-
-
{
-
success: true,
-
message: "Transferred #{stats[:job_listings]} job listings, #{stats[:interview_applications]} applications, " \
-
"#{stats[:users_current]} current users, #{stats[:user_targets]} target users, " \
-
"and #{stats[:email_senders]} email senders."
-
}
-
rescue ActiveRecord::RecordNotUnique => e
-
Rails.logger.error("Company merge failed due to duplicate key: #{e.message}")
-
{ success: false, error: "Merge failed: Some records already exist on the target company." }
-
rescue ActiveRecord::RecordNotDestroyed => e
-
Rails.logger.error("Company merge failed - could not delete source: #{e.message}")
-
{ success: false, error: "Merge failed: Could not delete the source company. #{e.record.errors.full_messages.join(', ')}" }
-
rescue => e
-
Rails.logger.error("Company merge failed: #{e.class} - #{e.message}")
-
{ success: false, error: "Merge failed: #{e.message}" }
-
end
-
end
-
# frozen_string_literal: true
-
-
# CompanyFeedback model representing overall feedback from company for entire application process
-
class CompanyFeedback < ApplicationRecord
-
FEEDBACK_TYPES = %w[rejection offer general withdrawal on_hold].freeze
-
-
belongs_to :interview_application
-
belongs_to :source_email, class_name: "SyncedEmail", optional: true, foreign_key: :source_email_id
-
-
validates :interview_application, presence: true
-
-
scope :recent, -> { order(received_at: :desc, created_at: :desc) }
-
scope :with_rejection, -> { where.not(rejection_reason: nil) }
-
scope :by_type, ->(type) { where(feedback_type: type) }
-
-
# Checks if this is a rejection feedback
-
# @return [Boolean] True if rejection reason exists
-
def rejection?
-
rejection_reason.present?
-
end
-
-
# Checks if feedback has been received
-
# @return [Boolean] True if received_at is set
-
def received?
-
received_at.present?
-
end
-
-
# Returns a summary of the feedback
-
# @return [String] Feedback summary
-
def summary
-
feedback_text.presence || "No feedback yet"
-
end
-
-
# Checks if feedback has next steps
-
# @return [Boolean] True if next steps exist
-
def has_next_steps?
-
next_steps.present?
-
end
-
-
# Returns sentiment of the feedback
-
# @return [String] Sentiment (positive, negative, neutral)
-
def sentiment
-
return "negative" if rejection?
-
return "positive" if has_next_steps?
-
"neutral"
-
end
-
-
# Checks if this feedback is from an email
-
# @return [Boolean] True if from email
-
def from_email?
-
source_email_id.present?
-
end
-
-
# Returns friendly feedback type name
-
# @return [String] Feedback type display name
-
def feedback_type_display
-
case feedback_type
-
when "rejection" then "Rejection"
-
when "offer" then "Job Offer"
-
when "general" then "General Feedback"
-
when "withdrawal" then "Position Withdrawn"
-
when "on_hold" then "On Hold"
-
else feedback_type&.titleize || "Unknown"
-
end
-
end
-
-
# Checks if this is an offer feedback
-
# @return [Boolean] True if offer
-
def offer?
-
feedback_type == "offer"
-
end
-
end
-
# frozen_string_literal: true
-
-
# Adds a soft-disable mechanism to a model via a `disabled_at` timestamp.
-
#
-
# Usage:
-
# class Company < ApplicationRecord
-
# include Disableable
-
# end
-
#
-
# Provides:
-
# - `enabled` / `disabled` scopes
-
# - `disabled?`
-
# - `disable!` / `enable!`
-
1
module Disableable
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
1
scope :enabled, -> { where(disabled_at: nil) }
-
1
scope :disabled, -> { where.not(disabled_at: nil) }
-
end
-
-
1
def disabled?
-
disabled_at.present?
-
end
-
-
1
def disable!
-
update!(disabled_at: Time.current)
-
end
-
-
1
def enable!
-
update!(disabled_at: nil)
-
end
-
end
-
1
module Transitionable
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
2
include AASM
-
-
2
has_many :transitions, as: :resource, dependent: :destroy
-
end
-
-
1
def transitioned?(status)
-
transitions.pluck(:from_state).include? status
-
end
-
-
1
def transitioned_at(status)
-
then: 0
else: 0
transitions.where(from_state: status.to_s.downcase).last&.created_at
-
end
-
end
-
# frozen_string_literal: true
-
-
# ConnectedAccount model for storing OAuth credentials from external providers
-
# Supports Gmail, and future integrations like LinkedIn, Outlook
-
class ConnectedAccount < ApplicationRecord
-
PROVIDERS = %w[google_oauth2].freeze
-
-
belongs_to :user
-
-
has_many :synced_emails, dependent: :destroy
-
-
# Encrypt sensitive token data at rest
-
encrypts :access_token, deterministic: false
-
encrypts :refresh_token, deterministic: false
-
-
# Validations
-
validates :provider, presence: true, inclusion: { in: PROVIDERS }
-
validates :uid, presence: true
-
# Allow multiple accounts per user (removed user_id+provider uniqueness)
-
# But prevent same Google account (provider+uid) from being connected to multiple users
-
validates :provider, uniqueness: { scope: :uid, message: "account already connected to another user" }
-
-
# Scopes
-
scope :google, -> { where(provider: "google_oauth2") }
-
scope :sync_enabled, -> { where(sync_enabled: true) }
-
scope :expired, -> { where("expires_at < ?", Time.current) }
-
scope :valid_tokens, -> { where("expires_at > ? OR expires_at IS NULL", Time.current) }
-
scope :needs_reauth, -> { where(needs_reauth: true) }
-
scope :ready_for_sync, -> { where(needs_reauth: false) }
-
scope :expiring_soon, -> { where("expires_at < ?", 1.hour.from_now) }
-
-
# Checks if the access token is expired
-
# @return [Boolean]
-
def token_expired?
-
expires_at.present? && expires_at < Time.current
-
end
-
-
# Checks if the token will expire soon (within 5 minutes)
-
# @return [Boolean]
-
def token_expiring_soon?
-
expires_at.present? && expires_at < 5.minutes.from_now
-
end
-
-
# Checks if we can refresh the token
-
# @return [Boolean]
-
def refreshable?
-
refresh_token.present?
-
end
-
-
# Returns true if this is a Google account
-
# @return [Boolean]
-
def google?
-
provider == "google_oauth2"
-
end
-
-
# Updates tokens from OAuth response
-
# @param auth [OmniAuth::AuthHash] The OAuth response
-
# @return [Boolean]
-
def update_from_oauth(auth)
-
update(
-
access_token: auth.credentials.token,
-
refresh_token: auth.credentials.refresh_token || refresh_token,
-
expires_at: auth.credentials.expires_at ? Time.at(auth.credentials.expires_at) : nil,
-
email: auth.info.email
-
)
-
end
-
-
# Creates or updates a connected account from OAuth response
-
# @param user [User] The user to connect
-
# @param auth [OmniAuth::AuthHash] The OAuth response
-
# @return [ConnectedAccount]
-
def self.from_oauth(user, auth)
-
account = user.connected_accounts.find_or_initialize_by(
-
provider: auth.provider,
-
uid: auth.uid
-
)
-
-
account.assign_attributes(
-
access_token: auth.credentials.token,
-
refresh_token: auth.credentials.refresh_token || account.refresh_token,
-
expires_at: auth.credentials.expires_at ? Time.at(auth.credentials.expires_at) : nil,
-
email: auth.info.email,
-
scopes: auth.credentials.scope,
-
# Clear reauth flags on successful reconnection
-
needs_reauth: false,
-
auth_error_at: nil,
-
auth_error_message: nil,
-
# Re-enable sync if it was disabled due to auth failure
-
sync_enabled: account.sync_enabled? || account.needs_reauth?
-
)
-
-
account.save!
-
account
-
end
-
-
# Mark the account as synced
-
# @return [Boolean]
-
def mark_synced!
-
update(last_synced_at: Time.current)
-
end
-
-
# Mark the account as needing reauthorization
-
# @param error_message [String, nil] Optional error message
-
# @return [Boolean]
-
def mark_needs_reauth!(error_message = nil)
-
update!(
-
needs_reauth: true,
-
auth_error_at: Time.current,
-
auth_error_message: error_message,
-
sync_enabled: false
-
)
-
end
-
-
# Clear the reauth requirement
-
# @return [Boolean]
-
def clear_reauth!
-
update!(
-
needs_reauth: false,
-
auth_error_at: nil,
-
auth_error_message: nil
-
)
-
end
-
end
-
1
class Current < ActiveSupport::CurrentAttributes
-
1
attribute :session
-
1
delegate :user, to: :session, allow_nil: true
-
end
-
# frozen_string_literal: true
-
-
# Developer model for TechWright SSO authenticated users
-
#
-
# Stores developer accounts that can access the internal admin portal.
-
# Completely separate from the User model - developers authenticate
-
# via TechWright SSO and don't need a User account.
-
#
-
# @example Finding or creating a developer from OAuth
-
# developer = Developer.find_or_create_from_omniauth(auth)
-
# developer.record_login!(ip_address: request.remote_ip)
-
#
-
class Developer < ApplicationRecord
-
# Encrypt sensitive OAuth tokens at rest
-
encrypts :access_token, :refresh_token
-
-
# Validations
-
validates :techwright_uid, presence: true, uniqueness: true
-
validates :email, presence: true
-
-
# Scopes
-
scope :enabled, -> { where(enabled: true) }
-
scope :disabled, -> { where(enabled: false) }
-
scope :recently_active, -> { where("last_login_at > ?", 30.days.ago) }
-
-
# Finds or creates a Developer from OmniAuth authentication data
-
#
-
# @param auth [OmniAuth::AuthHash] The OAuth authentication data from TechWright
-
# @return [Developer] The found or created developer record
-
def self.find_or_create_from_omniauth(auth)
-
developer = find_or_initialize_by(techwright_uid: auth.uid)
-
developer.update!(
-
email: auth.info.email,
-
name: auth.info.name,
-
avatar_url: auth.info.image,
-
access_token: auth.credentials.token,
-
refresh_token: auth.credentials.refresh_token,
-
token_expires_at: auth.credentials.expires_at ? Time.at(auth.credentials.expires_at) : nil
-
)
-
developer
-
end
-
-
# Records a login event for audit purposes
-
#
-
# @param ip_address [String] The IP address of the login request
-
# @return [Boolean] True if the update was successful
-
def record_login!(ip_address:)
-
update!(
-
last_login_at: Time.current,
-
last_login_ip: ip_address,
-
login_count: (login_count || 0) + 1
-
)
-
end
-
-
# Checks if the developer account is enabled
-
#
-
# @return [Boolean] True if the developer can access the admin portal
-
def enabled?
-
enabled
-
end
-
-
# Checks if the OAuth token has expired
-
#
-
# @return [Boolean] True if the token has expired or will expire within 5 minutes
-
def token_expired?
-
return true if token_expires_at.nil?
-
-
token_expires_at < 5.minutes.from_now
-
end
-
end
-
# frozen_string_literal: true
-
-
# Domain model representing professional domains/industries (e.g., FinTech, SaaS, Healthcare)
-
# Used for user targeting and resume analysis
-
class Domain < ApplicationRecord
-
include Disableable
-
-
has_many :user_target_domains, dependent: :destroy
-
has_many :users_targeting, through: :user_target_domains, source: :user
-
-
validates :name, presence: true, uniqueness: true
-
-
normalizes :name, with: ->(name) { name.to_s.strip }
-
normalizes :slug, with: ->(slug) { slug.to_s.strip.downcase.gsub(/\s+/, "-").gsub(/[^a-z0-9\-]/, "") }
-
-
before_validation :generate_slug, if: -> { slug.blank? && name.present? }
-
-
scope :alphabetical, -> { order(:name) }
-
scope :search, ->(query) { where("name ILIKE ?", "%#{query}%") if query.present? }
-
-
# Returns a display name for the domain
-
# @return [String] Domain name
-
def display_name
-
name
-
end
-
-
private
-
-
# Generates a URL-friendly slug from the name
-
# @return [void]
-
def generate_slug
-
self.slug = name.to_s.strip.downcase.gsub(/\s+/, "-").gsub(/[^a-z0-9\-]/, "")
-
end
-
end
-
# frozen_string_literal: true
-
-
# EmailSender model for tracking unique email addresses and associating them with companies
-
# This helps build a contacts database for interview-related communications
-
#
-
# @example
-
# sender = EmailSender.find_or_create_from_email("recruiter@company.com", "Jane Doe")
-
# sender.assign_company!(company)
-
#
-
class EmailSender < ApplicationRecord
-
SENDER_TYPES = %w[recruiter hiring_manager hr ats_system company unknown].freeze
-
-
belongs_to :company, optional: true
-
belongs_to :auto_detected_company, class_name: "Company", optional: true
-
-
has_many :synced_emails, dependent: :nullify
-
-
# Validations
-
validates :email, presence: true, uniqueness: { case_sensitive: false }
-
validates :domain, presence: true
-
validates :sender_type, inclusion: { in: SENDER_TYPES }, allow_nil: true
-
-
# Normalizations
-
normalizes :email, with: ->(email) { email.strip.downcase }
-
normalizes :domain, with: ->(domain) { domain.strip.downcase }
-
-
# Scopes
-
scope :unassigned, -> { where(company_id: nil) }
-
scope :assigned, -> { where.not(company_id: nil) }
-
scope :verified, -> { where(verified: true) }
-
scope :unverified, -> { where(verified: false) }
-
scope :auto_detected, -> { where.not(auto_detected_company_id: nil).where(company_id: nil) }
-
scope :by_domain, ->(domain) { where(domain: domain.downcase) }
-
scope :recent, -> { order(last_seen_at: :desc) }
-
scope :most_active, -> { order(email_count: :desc) }
-
scope :alphabetical, -> { order(:email) }
-
-
# Callbacks
-
before_validation :extract_domain, if: -> { email.present? && domain.blank? }
-
before_validation :detect_sender_type, if: -> { sender_type.blank? }
-
-
# Finds or creates an EmailSender from an email address
-
#
-
# @param email [String] The email address
-
# @param name [String, nil] The sender's display name
-
# @return [EmailSender]
-
def self.find_or_create_from_email(email, name = nil)
-
return nil if email.blank?
-
-
sender = find_or_initialize_by(email: email.strip.downcase)
-
sender.name = name if name.present? && sender.name.blank?
-
sender.last_seen_at = Time.current
-
sender.email_count = (sender.email_count || 0) + 1 unless sender.new_record?
-
sender.save!
-
sender
-
rescue ActiveRecord::RecordNotUnique
-
# Handle race condition
-
find_by!(email: email.strip.downcase)
-
end
-
-
def self.sender_types_for_select
-
SENDER_TYPES.map { |type| [ type.titleize, type ] }
-
end
-
-
# Increments the email count and updates last seen timestamp
-
#
-
# @return [Boolean]
-
def record_email!
-
increment!(:email_count)
-
update!(last_seen_at: Time.current)
-
end
-
-
# Assigns a company to this sender (admin action)
-
#
-
# @param company [Company] The company to assign
-
# @param verify [Boolean] Whether to mark as verified
-
# @return [Boolean]
-
def assign_company!(company, verify: true)
-
update!(company: company, verified: verify)
-
end
-
-
# Returns the effective company (admin-assigned or auto-detected)
-
#
-
# @return [Company, nil]
-
def effective_company
-
company || auto_detected_company
-
end
-
-
# Checks if this sender has a company assigned
-
#
-
# @return [Boolean]
-
def has_company?
-
company_id.present? || auto_detected_company_id.present?
-
end
-
-
# Checks if this is from an ATS system
-
#
-
# @return [Boolean]
-
def ats_system?
-
sender_type == "ats_system"
-
end
-
-
# Returns a display name for the sender
-
#
-
# @return [String]
-
def display_name
-
name.presence || email
-
end
-
-
private
-
-
# Extracts domain from email address
-
#
-
# @return [void]
-
def extract_domain
-
return unless email.present?
-
-
self.domain = email.split("@").last&.downcase
-
end
-
-
# Detects sender type based on email patterns
-
#
-
# @return [void]
-
def detect_sender_type
-
return unless domain.present?
-
-
self.sender_type = if ats_domain?
-
"ats_system"
-
elsif recruiter_pattern?
-
"recruiter"
-
elsif hr_pattern?
-
"hr"
-
else
-
"unknown"
-
end
-
end
-
-
# Checks if domain is from a known ATS system
-
#
-
# @return [Boolean]
-
def ats_domain?
-
Gmail::SyncService::RECRUITER_DOMAINS.any? { |d| domain.include?(d) }
-
end
-
-
# Checks if email matches recruiter patterns
-
#
-
# @return [Boolean]
-
def recruiter_pattern?
-
email.match?(/recruit|talent|sourcing/i)
-
end
-
-
# Checks if email matches HR patterns
-
#
-
# @return [Boolean]
-
def hr_pattern?
-
email.match?(/\bhr\b|human.?resources|people.?ops/i)
-
end
-
end
-
# frozen_string_literal: true
-
-
# FitAssessment model representing a user's fit score for a specific item.
-
#
-
# The fittable is polymorphic and is expected to be owned by the same user:
-
# - Opportunity
-
# - SavedJob
-
# - InterviewApplication
-
#
-
# @example
-
# FitAssessment.create!(user: user, fittable: opportunity, score: 82, status: :computed)
-
#
-
class FitAssessment < ApplicationRecord
-
belongs_to :user
-
belongs_to :fittable, polymorphic: true
-
-
enum :status, { pending: 0, computed: 1, failed: 2 }, default: :pending
-
-
validates :user, presence: true
-
validates :fittable, presence: true
-
validates :score,
-
numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 100 },
-
allow_nil: true
-
-
validate :score_required_when_computed
-
validate :fittable_owned_by_user
-
-
private
-
-
def score_required_when_computed
-
return unless computed?
-
return if score.present?
-
-
errors.add(:score, "must be present when computed")
-
end
-
-
def fittable_owned_by_user
-
return unless fittable && user
-
return unless fittable.respond_to?(:user_id)
-
-
return if fittable.user_id == user_id
-
-
errors.add(:user, "must match the fittable's owner")
-
end
-
end
-
# frozen_string_literal: true
-
-
# HtmlScrapingLog model for tracking field-level HTML extraction results
-
#
-
# Records detailed information about what the Nokogiri scraping step
-
# was able to extract, which selectors matched, and extraction quality.
-
#
-
# @example
-
# log = HtmlScrapingLog.create!(
-
# scraping_attempt: attempt,
-
# url: "https://example.com/job",
-
# domain: "example.com",
-
# field_results: {
-
# title: { success: true, value: "Software Engineer", selector: "h1.job-title" },
-
# location: { success: false, selectors_tried: ["[data-location]", ".location"] }
-
# }
-
# )
-
class HtmlScrapingLog < ApplicationRecord
-
STATUSES = [ :success, :partial, :failed ].freeze
-
-
TRACKED_FIELDS = %w[
-
title
-
location
-
remote_type
-
salary_min
-
salary_max
-
salary_currency
-
description
-
company_name
-
about_company
-
company_culture
-
requirements
-
responsibilities
-
benefits
-
].freeze
-
-
# Associations
-
belongs_to :scraping_attempt
-
belongs_to :job_listing, optional: true
-
-
# Enums
-
enum :status, {
-
success: 0, # All or most fields extracted
-
partial: 1, # Some fields extracted
-
failed: 2 # No fields extracted or error
-
}, default: :partial
-
-
# Validations
-
validates :url, presence: true
-
validates :domain, presence: true
-
-
# Scopes
-
scope :recent, -> { order(created_at: :desc) }
-
scope :by_domain, ->(domain) { where(domain: domain) }
-
scope :successful, -> { where(status: :success) }
-
scope :failed, -> { where(status: :failed) }
-
scope :recent_period, ->(days = 7) { where("created_at > ?", days.days.ago) }
-
-
# Callbacks
-
before_save :calculate_metrics
-
-
# Returns fields that were successfully extracted
-
#
-
# @return [Array<String>] Field names
-
def extracted_fields
-
return [] unless field_results.is_a?(Hash)
-
-
field_results.select { |_, v| v.is_a?(Hash) && v["success"] }.keys
-
end
-
-
# Returns fields that failed to extract
-
#
-
# @return [Array<String>] Field names
-
def failed_fields
-
return [] unless field_results.is_a?(Hash)
-
-
field_results.reject { |_, v| v.is_a?(Hash) && v["success"] }.keys
-
end
-
-
# Returns extraction result for a specific field
-
#
-
# @param [String, Symbol] field_name The field name
-
# @return [Hash, nil] Field result or nil
-
def field_result(field_name)
-
field_results[field_name.to_s]
-
end
-
-
# Checks if a field was successfully extracted
-
#
-
# @param [String, Symbol] field_name The field name
-
# @return [Boolean] True if extracted
-
def field_extracted?(field_name)
-
result = field_result(field_name)
-
result.is_a?(Hash) && result["success"]
-
end
-
-
# Returns the selector that matched for a field
-
#
-
# @param [String, Symbol] field_name The field name
-
# @return [String, nil] Selector or nil
-
def matched_selector(field_name)
-
result = field_result(field_name)
-
result["selector"] if result.is_a?(Hash)
-
end
-
-
# Returns formatted duration
-
#
-
# @return [String] Formatted duration
-
def formatted_duration
-
return "N/A" if duration_ms.nil?
-
-
if duration_ms < 1000
-
"#{duration_ms}ms"
-
else
-
"#{(duration_ms / 1000.0).round(2)}s"
-
end
-
end
-
-
# Returns extraction rate as percentage
-
#
-
# @return [String] Formatted percentage
-
def extraction_rate_display
-
return "N/A" if extraction_rate.nil?
-
-
"#{(extraction_rate * 100).round(0)}%"
-
end
-
-
# Returns status badge color
-
#
-
# @return [String] Tailwind color class
-
def status_badge_color
-
case status&.to_sym
-
when :success then "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400"
-
when :partial then "bg-amber-100 text-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
-
when :failed then "bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400"
-
else "bg-slate-100 text-slate-800 dark:bg-slate-700 dark:text-slate-300"
-
end
-
end
-
-
# Class method to calculate aggregate metrics for a domain
-
#
-
# @param [String] domain The domain
-
# @param [Integer] days Number of days to look back
-
# @return [Hash] Aggregate metrics
-
def self.domain_metrics(domain, days: 7)
-
logs = by_domain(domain).recent_period(days)
-
return {} if logs.count.zero?
-
-
# Calculate per-field success rates
-
field_stats = {}
-
TRACKED_FIELDS.each do |field|
-
total = 0
-
success = 0
-
logs.find_each do |log|
-
result = log.field_result(field)
-
next unless result.is_a?(Hash)
-
-
total += 1
-
success += 1 if result["success"]
-
end
-
field_stats[field] = {
-
total: total,
-
success: success,
-
rate: total > 0 ? (success.to_f / total * 100).round(1) : 0
-
}
-
end
-
-
{
-
total_attempts: logs.count,
-
avg_extraction_rate: logs.average(:extraction_rate).to_f.round(2),
-
avg_duration_ms: logs.average(:duration_ms).to_f.round(0),
-
by_status: logs.group(:status).count,
-
field_stats: field_stats
-
}
-
end
-
-
private
-
-
# Calculates summary metrics before save
-
def calculate_metrics
-
return unless field_results.is_a?(Hash)
-
-
self.fields_attempted = field_results.keys.count
-
self.fields_extracted = extracted_fields.count
-
self.extraction_rate = fields_attempted > 0 ? fields_extracted.to_f / fields_attempted : 0.0
-
-
# Determine status based on extraction rate
-
self.status = if extraction_rate >= 0.7
-
:success
-
elsif extraction_rate > 0
-
:partial
-
else
-
:failed
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# InterviewApplication model representing a job application tracking entry
-
1
class InterviewApplication < ApplicationRecord
-
1
include Transitionable
-
1
extend FriendlyId
-
1
friendly_id :uuid, use: [ :slugged, :finders ]
-
-
1
STATUSES = [ :active, :archived, :rejected, :accepted, :on_hold, :withdrawn ].freeze
-
1
PIPELINE_STAGES = [ :applied, :screening, :interviewing, :offer, :closed ].freeze
-
-
1
belongs_to :user
-
1
belongs_to :job_listing, optional: true
-
1
belongs_to :company
-
1
belongs_to :job_role
-
-
1
has_many :interview_rounds, dependent: :destroy, foreign_key: :interview_application_id
-
1
has_many :application_skill_tags, dependent: :destroy, foreign_key: :interview_id
-
1
has_many :skill_tags, through: :application_skill_tags
-
1
has_one :company_feedback, dependent: :destroy, foreign_key: :interview_application_id
-
1
has_many :synced_emails, dependent: :nullify
-
1
has_one :opportunity, dependent: :nullify
-
1
has_one :fit_assessment, as: :fittable, dependent: :destroy
-
1
has_many :interview_prep_artifacts, dependent: :destroy
-
-
1
validates :user, presence: true
-
1
validates :company, presence: true
-
1
validates :job_role, presence: true
-
-
1
scope :not_deleted, -> { where(deleted_at: nil) }
-
1
scope :deleted, -> { where.not(deleted_at: nil) }
-
-
# Status state machine
-
1
aasm column: :status, with_klass: BaseAasm do
-
1
requires_guards!
-
1
log_transitions!
-
-
1
state :active, initial: true
-
1
state :archived
-
1
state :rejected
-
1
state :accepted
-
1
state :on_hold
-
1
state :withdrawn
-
-
1
event :archive do
-
1
transitions from: :active, to: :archived
-
end
-
-
1
event :reject do
-
1
transitions from: :active, to: :rejected
-
end
-
-
1
event :accept do
-
1
transitions from: :active, to: :accepted
-
end
-
-
1
event :hold do
-
1
transitions from: :active, to: :on_hold
-
end
-
-
1
event :withdraw do
-
1
transitions from: :active, to: :withdrawn
-
end
-
-
1
event :reactivate do
-
1
transitions from: [ :archived, :rejected, :accepted, :on_hold, :withdrawn ], to: :active
-
end
-
end
-
-
# Pipeline stage state machine
-
1
aasm :pipeline_stage, column: :pipeline_stage, with_klass: BaseAasm do
-
1
requires_guards!
-
1
log_transitions!
-
-
1
state :applied, initial: true
-
1
state :screening
-
1
state :interviewing
-
1
state :offer
-
1
state :closed
-
-
1
event :move_to_screening do
-
1
transitions from: [ :applied, :interviewing ], to: :screening
-
end
-
-
1
event :move_to_interviewing do
-
1
transitions from: [ :applied, :screening, :offer ], to: :interviewing
-
end
-
-
1
event :move_to_offer do
-
1
transitions from: [ :screening, :interviewing ], to: :offer
-
end
-
-
1
event :move_to_closed do
-
1
transitions from: [ :applied, :screening, :interviewing, :offer ], to: :closed
-
end
-
-
1
event :move_to_applied do
-
1
transitions from: [ :screening, :interviewing ], to: :applied
-
end
-
end
-
-
1
scope :recent, -> { order(created_at: :desc) }
-
1
scope :by_status, ->(status) { where(status: status) }
-
1
scope :by_pipeline_stage, ->(stage) { where(pipeline_stage: stage) }
-
1
scope :with_active_rounds, -> { joins(:interview_rounds).where(interview_rounds: { result: :pending }).distinct }
-
-
# Set uuid early so FriendlyId can use it for slug generation
-
# (FriendlyId runs in before_validation, before before_create)
-
1
before_validation :set_uuid, on: :create
-
1
before_create :set_applied_at
-
-
# Returns a short summary for display in cards
-
# @return [String] Summary text
-
1
def card_summary
-
ai_summary.presence || "#{display_company.name} - #{display_job_role.title}"
-
end
-
-
# Returns the best available company for display
-
#
-
# Prefers the job_listing's company when extraction has completed and
-
# produced a non-placeholder result. Falls back to the application's
-
# own company association.
-
#
-
# @return [Company] The company to display
-
1
def display_company
-
# If we have a job listing with extraction completed and valid company, use it
-
then: 0
else: 0
then: 0
else: 0
if job_listing&.extraction_completed? && job_listing.company.present?
-
jl_company = job_listing.company
-
# Prefer job_listing's company unless it's also a placeholder
-
else: 0
then: 0
unless placeholder_company?(jl_company)
-
return jl_company
-
end
-
end
-
-
# Fall back to application's own company
-
company
-
end
-
-
# Returns the best available job role for display
-
#
-
# @return [JobRole] The job role to display
-
1
def display_job_role
-
# If we have a job listing with extraction completed and valid job role, use it
-
then: 0
else: 0
then: 0
else: 0
if job_listing&.extraction_completed? && job_listing.job_role.present?
-
jl_role = job_listing.job_role
-
else: 0
then: 0
unless placeholder_job_role?(jl_role)
-
return jl_role
-
end
-
end
-
-
# Fall back to application's own job role
-
job_role
-
end
-
-
# Checks if this application has any interview rounds
-
# @return [Boolean] True if rounds exist
-
1
def has_rounds?
-
interview_rounds.exists?
-
end
-
-
# Returns the most recent interview round
-
# @return [InterviewRound, nil] Most recent round or nil
-
1
def latest_round
-
interview_rounds.ordered.last
-
end
-
-
# Returns count of completed rounds
-
# @return [Integer] Count of completed rounds
-
1
def completed_rounds_count
-
interview_rounds.completed.count
-
end
-
-
# Returns count of total rounds
-
# @return [Integer] Total count of rounds
-
1
def total_rounds_count
-
interview_rounds.count
-
end
-
-
# Checks if application has company feedback
-
# @return [Boolean] True if company feedback exists
-
1
def has_company_feedback?
-
company_feedback.present?
-
end
-
-
# Returns count of pending rounds
-
# @return [Integer] Count of pending rounds
-
1
def pending_rounds_count
-
interview_rounds.where(result: :pending).count
-
end
-
-
# Returns badge color for status
-
# @return [String] Color name for badge
-
1
def status_badge_color
-
when: 0
case status.to_sym
-
when: 0
when :active then "blue"
-
when: 0
when :accepted then "green"
-
when: 0
when :rejected then "red"
-
when: 0
when :archived then "gray"
-
when: 0
when :on_hold then "yellow"
-
else: 0
when :withdrawn then "gray"
-
else "gray"
-
end
-
end
-
-
# Returns formatted pipeline stage name
-
# @return [String] Formatted stage name
-
1
def pipeline_stage_display
-
pipeline_stage.to_s.titleize
-
end
-
-
# @return [Boolean] Whether this application is soft-deleted.
-
1
def deleted?
-
deleted_at.present?
-
end
-
-
# Soft delete (move to trash). This does not destroy dependent records.
-
#
-
# @return [Boolean] true if persisted successfully
-
1
def soft_delete!
-
then: 0
else: 0
return false if deleted?
-
-
# Use update_columns to avoid triggering FriendlyId/AASM callbacks that may
-
# regenerate slugs or block persistence for unrelated reasons.
-
update_columns(deleted_at: Time.current, updated_at: Time.current)
-
end
-
-
# Restore a soft-deleted application.
-
#
-
# @return [Boolean] true if persisted successfully
-
1
def restore!
-
else: 0
then: 0
return false unless deleted?
-
-
update_columns(deleted_at: nil, updated_at: Time.current)
-
end
-
-
# Returns a scheduling link from synced emails if available
-
# Prioritizes links from scheduling-type emails or high-priority action links
-
#
-
# @return [Hash, nil] { url: String, platform: String } or nil
-
1
def scheduling_link
-
@scheduling_link ||= find_scheduling_link_from_emails
-
end
-
-
# Checks if this application has a scheduling link available
-
#
-
# @return [Boolean] True if scheduling link exists
-
1
def has_scheduling_link?
-
scheduling_link.present?
-
end
-
-
# Checks if the next interview needs to be scheduled
-
# True if no upcoming rounds exist
-
#
-
# @return [Boolean] True if interview not yet scheduled
-
1
def needs_scheduling?
-
interview_rounds.upcoming.none?
-
end
-
-
# Returns scheduling link only if interview needs scheduling
-
#
-
# @return [Hash, nil] { url: String, platform: String } or nil
-
1
def actionable_scheduling_link
-
else: 0
then: 0
return nil unless needs_scheduling?
-
scheduling_link
-
end
-
-
1
private
-
-
# Finds scheduling link from synced emails
-
#
-
# @return [Hash, nil]
-
1
def find_scheduling_link_from_emails
-
synced_emails.each do |email|
-
else: 0
then: 0
next unless email.signal_action_links.is_a?(Array)
-
-
# Look for scheduling links (priority 1 or label contains "schedule")
-
email.signal_action_links.each do |link|
-
then: 0
else: 0
then: 0
else: 0
then: 0
else: 0
if link["priority"] == 1 || link["action_label"]&.downcase&.include?("schedule")
-
return {
-
url: link["url"],
-
platform: extract_platform_name(link["url"]),
-
label: link["action_label"]
-
}
-
end
-
end
-
end
-
nil
-
end
-
-
# Extracts friendly platform name from URL
-
#
-
# @param url [String]
-
# @return [String]
-
1
def extract_platform_name(url)
-
when: 0
case url
-
when: 0
when /goodtime\.io/i then "GoodTime"
-
when: 0
when /calendly\.com/i then "Calendly"
-
when: 0
when /cal\.com/i then "Cal.com"
-
when: 0
when /doodle\.com/i then "Doodle"
-
else: 0
when /zoom\.us/i then "Zoom"
-
else "scheduling link"
-
end
-
end
-
-
1
PLACEHOLDER_COMPANY_NAMES = [ "unknown company", "unknown" ].freeze
-
1
PLACEHOLDER_JOB_ROLES = [ "unknown position", "unknown role", "unknown" ].freeze
-
-
1
def placeholder_company?(comp)
-
then: 0
else: 0
return true if comp.nil?
-
then: 0
else: 0
then: 0
else: 0
PLACEHOLDER_COMPANY_NAMES.any? { |p| comp.name&.downcase&.include?(p) }
-
end
-
-
1
def placeholder_job_role?(role)
-
then: 0
else: 0
return true if role.nil?
-
then: 0
else: 0
then: 0
else: 0
PLACEHOLDER_JOB_ROLES.any? { |p| role.title&.downcase&.include?(p) }
-
end
-
-
1
def set_uuid
-
self.uuid = SecureRandom.uuid
-
end
-
-
1
def set_applied_at
-
then: 0
else: 0
self.applied_at = Time.current if applied_at.blank?
-
end
-
end
-
# frozen_string_literal: true
-
-
# InterviewFeedback model representing self-reflection and notes for an interview round
-
class InterviewFeedback < ApplicationRecord
-
self.table_name = "interview_feedbacks"
-
-
belongs_to :interview_round
-
-
serialize :tags, coder: JSON
-
-
attribute :tags, default: -> { [] }
-
-
validates :interview_round, presence: true
-
-
scope :recent, -> { order(created_at: :desc) }
-
scope :with_recommendations, -> { where.not(recommended_action: nil) }
-
-
# Returns tags as an array
-
# @return [Array<String>] Array of tag strings
-
def tag_list
-
Array.wrap(tags)
-
end
-
-
# Sets tags from an array or comma-separated string
-
# @param value [Array, String] Tags to set
-
def tag_list=(value)
-
self.tags = if value.is_a?(String)
-
value.split(",").map(&:strip).reject(&:blank?)
-
else
-
Array.wrap(value)
-
end
-
end
-
-
# Checks if this feedback has an AI summary
-
# @return [Boolean] True if AI summary exists
-
def has_ai_summary?
-
ai_summary.present?
-
end
-
-
# Returns a short summary of what went well
-
# @return [String] Truncated summary
-
def summary_preview
-
went_well.presence&.truncate(100) || "No feedback yet"
-
end
-
end
-
-
# frozen_string_literal: true
-
-
# InterviewPrepArtifact stores cached, structured interview prep content for a specific application.
-
#
-
# Artifacts are generated per section (kind) and are idempotent via inputs_digest.
-
class InterviewPrepArtifact < ApplicationRecord
-
KINDS = [ :match_analysis, :focus_areas, :question_framing, :strength_positioning ].freeze
-
STATUSES = [ :pending, :computed, :failed ].freeze
-
-
belongs_to :interview_application
-
belongs_to :user
-
belongs_to :llm_api_log, class_name: "Ai::LlmApiLog", optional: true
-
-
enum :kind, KINDS
-
enum :status, STATUSES, default: :pending
-
-
validates :uuid, presence: true, uniqueness: true
-
validates :kind, presence: true, inclusion: { in: KINDS.map(&:to_s) }
-
validates :status, presence: true, inclusion: { in: STATUSES.map(&:to_s) }
-
validates :inputs_digest, presence: true
-
-
validate :application_owned_by_user
-
-
before_validation :ensure_uuid, on: :create
-
-
scope :recent_first, -> { order(updated_at: :desc, created_at: :desc) }
-
-
private
-
-
def ensure_uuid
-
self.uuid ||= SecureRandom.uuid
-
end
-
-
def application_owned_by_user
-
return if interview_application.nil? || user.nil?
-
return if interview_application.user_id == user_id
-
-
errors.add(:user, "must match the interview application's owner")
-
end
-
end
-
# frozen_string_literal: true
-
-
# InterviewRound model representing individual interview rounds in an application process
-
class InterviewRound < ApplicationRecord
-
STAGES = [ :screening, :technical, :hiring_manager, :culture_fit, :other ].freeze
-
RESULTS = [ :pending, :passed, :failed, :waitlisted, :cancelled ].freeze
-
CONFIRMATION_SOURCES = %w[calendly goodtime greenhouse lever manual other].freeze
-
-
belongs_to :interview_application
-
belongs_to :source_email, class_name: "SyncedEmail", optional: true, foreign_key: :source_email_id
-
belongs_to :interview_round_type, optional: true
-
has_one :interview_feedback, dependent: :destroy
-
has_many :prep_artifacts, class_name: "InterviewRoundPrepArtifact", dependent: :destroy
-
-
enum :stage, STAGES, default: :screening
-
enum :result, RESULTS, default: :pending
-
-
validates :interview_application, presence: true
-
validates :stage, presence: true, inclusion: { in: STAGES.map(&:to_s) }
-
validates :result, inclusion: { in: RESULTS.map(&:to_s) }
-
-
scope :by_stage, ->(stage) { where(stage: stage) }
-
scope :completed, -> { where.not(completed_at: nil) }
-
scope :upcoming, -> { where(completed_at: nil).where("scheduled_at > ?", Time.current) }
-
scope :ordered, -> { order(position: :asc, scheduled_at: :asc, created_at: :asc) }
-
-
# Returns display name for the stage
-
# @return [String] Stage display name
-
def stage_display_name
-
stage_name.presence || stage.to_s.humanize
-
end
-
-
# Alias for stage_display_name for consistency
-
alias_method :stage_display, :stage_display_name
-
-
# Checks if round is completed
-
# @return [Boolean] True if completed
-
def completed?
-
completed_at.present?
-
end
-
-
# Checks if round is upcoming
-
# @return [Boolean] True if upcoming
-
def upcoming?
-
scheduled_at.present? && scheduled_at > Time.current && !completed?
-
end
-
-
# Returns duration in hours and minutes
-
# @return [String, nil] Formatted duration
-
def formatted_duration
-
return nil if duration_minutes.nil?
-
-
hours = duration_minutes / 60
-
minutes = duration_minutes % 60
-
-
if hours > 0 && minutes > 0
-
"#{hours}h #{minutes}m"
-
elsif hours > 0
-
"#{hours}h 0m"
-
else
-
"#{minutes}m"
-
end
-
end
-
-
# Returns badge color for result
-
# @return [String] Color name for badge
-
def result_badge_color
-
case result.to_sym
-
when :pending then "yellow"
-
when :passed then "green"
-
when :failed then "red"
-
when :waitlisted then "blue"
-
when :cancelled then "gray"
-
else "gray"
-
end
-
end
-
-
# Returns formatted interviewer information
-
# @return [String, nil] Formatted interviewer info
-
def interviewer_display
-
return nil if interviewer_name.blank?
-
return interviewer_name if interviewer_role.blank?
-
-
"#{interviewer_name} (#{interviewer_role})"
-
end
-
-
# Checks if round has a video link
-
# @return [Boolean] True if video link exists
-
def has_video_link?
-
video_link.present?
-
end
-
-
# Checks if round was created from email
-
# @return [Boolean] True if created from email
-
def from_email?
-
source_email_id.present?
-
end
-
-
# Returns friendly confirmation source name
-
# @return [String] Confirmation source display name
-
def confirmation_source_display
-
case confirmation_source
-
when "calendly" then "Calendly"
-
when "goodtime" then "GoodTime"
-
when "greenhouse" then "Greenhouse"
-
when "lever" then "Lever"
-
when "manual" then "Direct Email"
-
else confirmation_source&.titleize || "Unknown"
-
end
-
end
-
-
# Returns the round type name for display
-
# @return [String, nil] Round type name or nil if not set
-
def round_type_name
-
interview_round_type&.name
-
end
-
-
# Returns the round type slug for prep matching
-
# @return [String, nil] Round type slug or nil if not set
-
def round_type_slug
-
interview_round_type&.slug
-
end
-
-
# Returns the comprehensive prep artifact if it exists and is completed
-
# @return [InterviewRoundPrepArtifact, nil] The prep artifact or nil
-
def prep
-
prep_artifacts.completed.find_by(kind: :comprehensive)
-
end
-
-
# Checks if prep has been generated for this round
-
# @return [Boolean] True if prep exists and is completed
-
def has_prep?
-
prep.present?
-
end
-
end
-
# frozen_string_literal: true
-
-
# InterviewRoundPrepArtifact model for storing AI-generated interview preparation content.
-
#
-
# Each artifact stores a specific type of prep content (questions, strategies, patterns, tips)
-
# for a specific interview round. Uses inputs_digest for cache invalidation.
-
#
-
# @example
-
# artifact = InterviewRoundPrepArtifact.create!(
-
# interview_round: round,
-
# kind: :comprehensive,
-
# content: { questions: [...], strategies: [...] },
-
# status: :completed
-
# )
-
class InterviewRoundPrepArtifact < ApplicationRecord
-
# Artifact kinds - types of prep content
-
KINDS = [ :comprehensive, :questions, :strategies, :patterns, :tips, :checklist ].freeze
-
-
# Status values for generation workflow
-
STATUSES = [ :pending, :generating, :completed, :failed ].freeze
-
-
# Associations
-
belongs_to :interview_round
-
-
# Enums
-
enum :status, STATUSES, default: :pending
-
-
# Validations
-
validates :interview_round, presence: true
-
validates :kind, presence: true, inclusion: { in: KINDS.map(&:to_s) }
-
validates :kind, uniqueness: { scope: :interview_round_id, message: "already exists for this round" }
-
-
# Store accessors for common content fields
-
store_accessor :content,
-
:round_summary,
-
:expected_questions,
-
:your_history,
-
:company_patterns,
-
:preparation_checklist,
-
:answer_strategies,
-
:tips
-
-
# Scopes
-
scope :by_kind, ->(kind) { where(kind: kind) }
-
scope :completed, -> { where(status: :completed) }
-
scope :recent, -> { order(generated_at: :desc) }
-
-
# Checks if the artifact is stale based on inputs
-
#
-
# @param new_digest [String] The digest of current inputs
-
# @return [Boolean] True if stale and needs regeneration
-
def stale?(new_digest)
-
inputs_digest != new_digest
-
end
-
-
# Marks the artifact as completed with content
-
#
-
# @param new_content [Hash] The generated content
-
# @param digest [String] The inputs digest for cache invalidation
-
# @return [Boolean] True if save succeeded
-
def complete!(new_content, digest: nil)
-
self.content = new_content
-
self.inputs_digest = digest if digest
-
self.generated_at = Time.current
-
self.status = :completed
-
save!
-
end
-
-
# Marks the artifact as failed
-
#
-
# @param error_message [String] Optional error message to store
-
# @return [Boolean] True if save succeeded
-
def fail!(error_message = nil)
-
self.content = { error: error_message } if error_message
-
self.status = :failed
-
save!
-
end
-
-
# Returns the display name for the artifact kind
-
#
-
# @return [String] Human-readable kind name
-
def kind_display_name
-
kind.to_s.titleize
-
end
-
-
# Checks if the artifact has usable content
-
#
-
# @return [Boolean] True if completed with content
-
def has_content?
-
completed? && content.present? && !content.key?("error")
-
end
-
-
# Finds or initializes an artifact for a round and kind
-
#
-
# @param interview_round [InterviewRound] The round
-
# @param kind [Symbol, String] The artifact kind
-
# @return [InterviewRoundPrepArtifact] Found or new artifact
-
def self.find_or_initialize_for(interview_round:, kind:)
-
find_or_initialize_by(interview_round: interview_round, kind: kind)
-
end
-
end
-
# frozen_string_literal: true
-
-
# InterviewRoundType model representing granular interview round classifications.
-
#
-
# Round types are associated with departments (Categories) to enable per-department
-
# customization. A nil category means the round type is universal (available to all).
-
#
-
# Examples: "Coding Interview", "System Design", "Behavioral", "Case Study"
-
class InterviewRoundType < ApplicationRecord
-
include Disableable
-
-
# Associations
-
belongs_to :category, optional: true
-
has_many :interview_rounds, dependent: :nullify
-
-
# Validations
-
validates :name, presence: true
-
validates :slug, presence: true, uniqueness: true
-
-
# Normalizations
-
normalizes :slug, with: ->(s) { s.to_s.parameterize.underscore }
-
normalizes :name, with: ->(n) { n.to_s.strip }
-
-
# Scopes
-
scope :alphabetical, -> { order(:name) }
-
scope :ordered, -> { order(:position, :name) }
-
scope :universal, -> { where(category_id: nil) }
-
scope :for_department, ->(cat_id) { where(category_id: [ nil, cat_id ]) }
-
scope :search, ->(query) { where("name ILIKE ?", "%#{query}%") if query.present? }
-
-
# Returns the display name for this round type
-
#
-
# @return [String] The round type name
-
def display_name
-
name
-
end
-
-
# Returns the department name if associated with one
-
#
-
# @return [String, nil] The department name or nil if universal
-
def department_name
-
category&.name
-
end
-
-
# Alias for department (category with kind: job_role)
-
#
-
# @return [Category, nil]
-
def department
-
category
-
end
-
-
# Checks if this round type is universal (available to all departments)
-
#
-
# @return [Boolean] True if universal
-
def universal?
-
category_id.nil?
-
end
-
-
# Finds a round type by slug
-
#
-
# @param slug [String] The slug to search for
-
# @return [InterviewRoundType, nil] The round type or nil
-
def self.find_by_slug(slug)
-
find_by(slug: slug.to_s.parameterize.underscore)
-
end
-
end
-
# frozen_string_literal: true
-
-
# JobListing model representing job postings
-
1
class JobListing < ApplicationRecord
-
1
include Disableable
-
-
1
REMOTE_TYPES = [ :on_site, :hybrid, :remote ].freeze
-
1
STATUSES = [ :draft, :active, :closed ].freeze
-
1
EXTRACTION_QUALITIES = [ :full, :partial, :limited, :manual ].freeze
-
1
LIMITED_JOB_BOARDS = %w[linkedin indeed glassdoor].freeze
-
1
SALARY_CURRENCY_RE = /\A[A-Z]{3}\z/
-
1
MIN_ANNUAL_SALARY = 10_000
-
1
MAX_ANNUAL_SALARY = 2_000_000
-
-
1
belongs_to :company
-
1
belongs_to :job_role
-
1
has_many :interview_applications, dependent: :nullify
-
1
has_many :scraping_attempts, dependent: :destroy
-
1
has_many :scraped_job_listing_data, class_name: "ScrapedJobListingData", dependent: :destroy
-
1
has_many :llm_api_logs, class_name: "Ai::LlmApiLog", as: :loggable, dependent: :destroy
-
-
1
enum :remote_type, REMOTE_TYPES, default: :on_site
-
1
enum :status, STATUSES, default: :active
-
-
1
attribute :custom_sections, default: -> { {} }
-
1
attribute :scraped_data, default: -> { {} }
-
-
1
validates :company, presence: true
-
1
validates :job_role, presence: true
-
1
validates :remote_type, inclusion: { in: REMOTE_TYPES.map(&:to_s) }
-
1
validates :status, inclusion: { in: STATUSES.map(&:to_s) }
-
1
validate :url_has_safe_scheme, if: -> { url.present? }
-
-
1
scope :active, -> { where(status: :active) }
-
1
scope :closed, -> { where(status: :closed) }
-
1
scope :remote, -> { where(remote_type: :remote) }
-
1
scope :recent, -> { order(created_at: :desc) }
-
-
# Returns a display title for the job listing
-
# @return [String] Job listing title
-
1
def display_title
-
title.presence || job_role.title
-
end
-
-
# Returns salary range as a formatted string
-
# @return [String, nil] Formatted salary range
-
1
def salary_range
-
else: 0
then: 0
return nil unless salary_range_valid?
-
-
then: 0
else: 0
currency_symbol = salary_currency == "USD" ? "$" : salary_currency
-
then: 0
else: 0
min_formatted = salary_min ? number_with_delimiter(salary_min.to_i) : nil
-
then: 0
else: 0
max_formatted = salary_max ? number_with_delimiter(salary_max.to_i) : nil
-
-
then: 0
if min_formatted && max_formatted
-
else: 0
"#{currency_symbol}#{min_formatted} - #{currency_symbol}#{max_formatted} #{salary_currency}"
-
then: 0
elsif min_formatted
-
else: 0
"#{currency_symbol}#{min_formatted}+ #{salary_currency}"
-
then: 0
else: 0
elsif max_formatted
-
"Up to #{currency_symbol}#{max_formatted} #{salary_currency}"
-
end
-
end
-
-
# Returns whether salary_min/salary_max are safe to display.
-
#
-
# This is intentionally conservative: if the extracted salary looks implausible
-
# (too small, inverted range, invalid currency), we hide it.
-
#
-
# @return [Boolean]
-
1
def salary_range_valid?
-
then: 0
else: 0
return false if salary_min.nil? && salary_max.nil?
-
then: 0
else: 0
return false if salary_currency.blank? || salary_currency !~ SALARY_CURRENCY_RE
-
-
then: 0
else: 0
min = salary_min&.to_f
-
then: 0
else: 0
max = salary_max&.to_f
-
-
then: 0
else: 0
return false if min && (min < MIN_ANNUAL_SALARY || min > MAX_ANNUAL_SALARY)
-
then: 0
else: 0
return false if max && (max < MIN_ANNUAL_SALARY || max > MAX_ANNUAL_SALARY)
-
then: 0
else: 0
return false if min && max && max < min
-
-
true
-
end
-
-
# Checks if job listing has custom sections
-
# @return [Boolean] True if custom sections exist
-
1
def has_custom_sections?
-
custom_sections.present? && custom_sections.any?
-
end
-
-
# Checks if job listing was scraped
-
# @return [Boolean] True if scraped data exists
-
1
def scraped?
-
scraped_data.present? && scraped_data.any?
-
end
-
-
# Returns formatted remote type
-
# @return [String] Formatted remote type
-
1
def remote_type_display
-
remote_type.to_s.titleize.gsub("_", "-")
-
end
-
-
# Returns location with remote type
-
# @return [String] Formatted location display
-
1
def location_display
-
then: 0
if location.present?
-
"#{location} (#{remote_type_display})"
-
else: 0
else
-
remote_type_display
-
end
-
end
-
-
# Returns the latest scraping attempt
-
# @return [ScrapingAttempt, nil] Latest attempt or nil
-
1
def latest_scraping_attempt
-
scraping_attempts.order(created_at: :desc).first
-
end
-
-
# Returns extraction status from scraped_data
-
# @return [String] Extraction status
-
1
def extraction_status
-
scraped_data["status"] || "pending"
-
end
-
-
# Returns extraction confidence score
-
# @return [Float] Confidence score between 0 and 1
-
1
def extraction_confidence
-
scraped_data["confidence_score"] || 0.0
-
end
-
-
# Checks if extraction was successful
-
# @return [Boolean] True if extraction completed successfully
-
1
def extraction_completed?
-
extraction_status == "completed"
-
end
-
-
# Checks if extraction needs admin review
-
# @return [Boolean] True if needs review
-
1
def extraction_needs_review?
-
latest_attempt = latest_scraping_attempt
-
else: 0
then: 0
return false unless latest_attempt
-
-
latest_attempt.needs_review? || extraction_confidence < 0.7
-
end
-
-
# Returns the job board type from scraped_data
-
# @return [String, nil] Job board type (linkedin, greenhouse, etc.)
-
1
def job_board
-
scraped_data["job_board"] || job_board_id
-
end
-
-
# Returns extraction quality from scraped_data
-
# @return [String] Extraction quality (full, partial, limited, manual)
-
1
def extraction_quality
-
scraped_data["extraction_quality"] || "full"
-
end
-
-
# Checks if this job listing has limited extraction data
-
# (from sources like LinkedIn that require auth)
-
# @return [Boolean] True if extraction was limited
-
1
def limited_extraction?
-
extraction_quality == "limited" || LIMITED_JOB_BOARDS.include?(job_board.to_s)
-
end
-
-
# Checks if this job listing needs more details from the user
-
# @return [Boolean] True if more details would be helpful
-
1
def needs_more_details?
-
then: 0
else: 0
return true if limited_extraction?
-
then: 0
else: 0
return true if description.blank? && responsibilities.blank?
-
then: 0
else: 0
return true if extraction_confidence < 0.5
-
-
false
-
end
-
-
# Returns a human-readable explanation of why extraction was limited
-
# @return [String, nil] Explanation or nil if not limited
-
1
def limited_extraction_reason
-
else: 0
then: 0
return nil unless limited_extraction?
-
-
case job_board.to_s
-
when: 0
when "linkedin"
-
"LinkedIn requires authentication to access full job details. " \
-
"We extracted what was publicly available."
-
when: 0
when "indeed"
-
"Indeed limits public access to job details. " \
-
"Some information may be incomplete."
-
when: 0
when "glassdoor"
-
"Glassdoor requires authentication for full job details. " \
-
"We extracted what was publicly available."
-
else: 0
else
-
"This job listing has limited data due to source restrictions."
-
end
-
end
-
-
# Returns a safe URL for linking, or nil if URL is potentially dangerous
-
# Only allows http/https schemes to prevent javascript: XSS attacks
-
#
-
# @return [String, nil] Safe URL or nil
-
1
def safe_url
-
then: 0
else: 0
return nil if url.blank?
-
-
uri = URI.parse(url.strip)
-
then: 0
else: 0
then: 0
else: 0
%w[http https].include?(uri.scheme&.downcase) ? url : nil
-
rescue URI::InvalidURIError
-
nil
-
end
-
-
1
private
-
-
# Validates that the URL uses a safe scheme (http/https only)
-
# Prevents javascript:, data:, and other dangerous URL schemes
-
#
-
# @return [void]
-
1
def url_has_safe_scheme
-
then: 0
else: 0
return if url.blank?
-
-
begin
-
uri = URI.parse(url.strip)
-
then: 0
else: 0
else: 0
then: 0
unless %w[http https].include?(uri.scheme&.downcase)
-
errors.add(:url, "must use http or https")
-
end
-
rescue URI::InvalidURIError
-
errors.add(:url, "is not a valid URL")
-
end
-
end
-
-
1
def number_with_delimiter(number)
-
number.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
-
end
-
end
-
# frozen_string_literal: true
-
-
# JobRole model representing job positions/titles
-
class JobRole < ApplicationRecord
-
include Disableable
-
-
has_many :job_listings, dependent: :destroy
-
has_many :interview_applications, dependent: :nullify
-
has_many :users_with_current_role, class_name: "User", foreign_key: "current_job_role_id", dependent: :nullify
-
has_many :user_target_job_roles, dependent: :destroy
-
has_many :users_targeting, through: :user_target_job_roles, source: :user
-
belongs_to :category, optional: true
-
-
validates :title, presence: true, uniqueness: true
-
-
normalizes :title, with: ->(title) { title.strip }
-
-
scope :alphabetical, -> { order(:title) }
-
scope :by_category, ->(category_id) { where(category_id: category_id) }
-
scope :by_department, ->(department_id) { by_category(department_id) }
-
scope :with_department, -> { includes(:category).where.not(category_id: nil) }
-
scope :search, ->(query) { where("title ILIKE ?", "%#{query}%") if query.present? }
-
-
def legacy_category_name
-
respond_to?(:legacy_category) ? legacy_category : nil
-
end
-
-
# Returns a display name for the job role
-
# @return [String] Job role title
-
def display_name
-
title
-
end
-
-
# Returns category name (alias: department name)
-
# @return [String, nil]
-
def category_name
-
category&.name
-
end
-
-
# Alias for department (category with kind: job_role)
-
# @return [Category, nil]
-
def department
-
category
-
end
-
-
# Returns department name
-
# @return [String, nil]
-
def department_name
-
category&.name
-
end
-
-
# Returns available interview round types for this job role.
-
# Includes universal types (no department) and types specific to this role's department.
-
#
-
# @return [ActiveRecord::Relation<InterviewRoundType>] Available round types
-
def available_round_types
-
InterviewRoundType.enabled.for_department(category_id).ordered
-
end
-
-
# Merges a source job role into a target job role
-
#
-
# @param source [JobRole] The job role to be merged (will be deleted)
-
# @param target [JobRole] The job role to merge into
-
# @return [Hash] Result hash with :success, :message/:error keys
-
def self.merge_job_roles(source, target)
-
if source == target
-
return { success: false, error: "Cannot merge a job role into itself." }
-
end
-
-
if source.nil? || target.nil?
-
return { success: false, error: "Source or target job role not found." }
-
end
-
-
stats = {
-
job_listings: 0,
-
interview_applications: 0,
-
users_current: 0,
-
user_targets: 0
-
}
-
-
transaction do
-
# Transfer job_listings
-
stats[:job_listings] = JobListing.where(job_role: source).update_all(job_role_id: target.id)
-
-
# Transfer interview_applications
-
stats[:interview_applications] = InterviewApplication.where(job_role: source).update_all(job_role_id: target.id)
-
-
# Transfer users with current_job_role
-
stats[:users_current] = User.where(current_job_role_id: source.id).update_all(current_job_role_id: target.id)
-
-
# Handle duplicate user_target_job_roles
-
duplicate_target_ids = UserTargetJobRole.where(job_role: source)
-
.joins("INNER JOIN user_target_job_roles utjr2 ON user_target_job_roles.user_id = utjr2.user_id")
-
.where("utjr2.job_role_id = ?", target.id)
-
.pluck(:id)
-
UserTargetJobRole.where(id: duplicate_target_ids).delete_all
-
-
# Transfer remaining user_target_job_roles
-
stats[:user_targets] = UserTargetJobRole.where(job_role: source).update_all(job_role_id: target.id)
-
-
# Delete the source job role
-
source.destroy!
-
end
-
-
{
-
success: true,
-
message: "Transferred #{stats[:job_listings]} job listings, #{stats[:interview_applications]} applications, " \
-
"#{stats[:users_current]} current users, and #{stats[:user_targets]} target users."
-
}
-
rescue ActiveRecord::RecordNotUnique => e
-
Rails.logger.error("JobRole merge failed due to duplicate key: #{e.message}")
-
{ success: false, error: "Merge failed: Some records already exist on the target job role." }
-
rescue ActiveRecord::RecordNotDestroyed => e
-
Rails.logger.error("JobRole merge failed - could not delete source: #{e.message}")
-
{ success: false, error: "Merge failed: Could not delete the source job role. #{e.record.errors.full_messages.join(', ')}" }
-
rescue => e
-
Rails.logger.error("JobRole merge failed: #{e.class} - #{e.message}")
-
{ success: false, error: "Merge failed: #{e.message}" }
-
end
-
end
-
# frozen_string_literal: true
-
-
# LlmProviderConfig model for dynamic LLM provider configuration
-
#
-
# Allows runtime configuration of LLM providers without code deployment.
-
# Admins can enable/disable providers, change models, adjust parameters, etc.
-
class LlmProviderConfig < ApplicationRecord
-
PROVIDER_TYPES = [:openai, :anthropic, :ollama, :gemini].freeze
-
-
# Validations
-
validates :name, presence: true
-
validates :provider_type, presence: true, inclusion: { in: PROVIDER_TYPES.map(&:to_s) }
-
validates :llm_model, presence: true
-
validates :max_tokens, numericality: { greater_than: 0, less_than_or_equal_to: 100000 }, allow_nil: true
-
validates :temperature, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 2 }, allow_nil: true
-
validates :priority, numericality: { only_integer: true }, allow_nil: true
-
-
# Scopes
-
scope :enabled, -> { where(enabled: true) }
-
scope :disabled, -> { where(enabled: false) }
-
scope :by_priority, -> { order(priority: :asc, created_at: :asc) }
-
scope :by_provider_type, ->(type) { where(provider_type: type) }
-
-
# Returns all enabled providers in priority order
-
#
-
# @return [ActiveRecord::Relation] Enabled providers
-
def self.active_providers
-
enabled.by_priority
-
end
-
-
# Returns the default provider (highest priority enabled)
-
#
-
# @return [LlmProviderConfig, nil] Default provider or nil
-
def self.default_provider
-
active_providers.first
-
end
-
-
# Returns fallback providers (all except default)
-
#
-
# @return [ActiveRecord::Relation] Fallback providers
-
def self.fallback_providers
-
active_providers.offset(1)
-
end
-
-
# Checks if API key is configured for this provider
-
#
-
# @return [Boolean] True if API key exists
-
def api_key_configured?
-
api_key.present?
-
end
-
-
# Returns the API key from Rails credentials
-
#
-
# @return [String, nil] API key or nil
-
def api_key
-
case provider_type.to_sym
-
when :openai
-
Rails.application.credentials.dig(:openai, :api_key)
-
when :anthropic
-
Rails.application.credentials.dig(:anthropic, :api_key)
-
when :gemini
-
Rails.application.credentials.dig(:gemini, :api_key)
-
when :ollama
-
"local" # Ollama doesn't need an API key
-
else
-
nil
-
end
-
end
-
-
# Checks if provider is ready to use
-
#
-
# @return [Boolean] True if enabled and has API key
-
def ready?
-
enabled? && api_key_configured?
-
end
-
-
# Returns display name with model info
-
#
-
# @return [String] Display name
-
def display_name
-
"#{name} (#{llm_model})"
-
end
-
-
# Returns configuration hash for provider instantiation
-
#
-
# @return [Hash] Configuration hash
-
def to_config
-
{
-
provider_type: provider_type,
-
model: llm_model,
-
max_tokens: max_tokens,
-
temperature: temperature,
-
api_endpoint: api_endpoint,
-
enabled: enabled,
-
settings: settings
-
}.compact
-
end
-
end
-
# frozen_string_literal: true
-
-
# NewsletterSubscriber stores email-only newsletter signups.
-
#
-
# Mailkick subscriptions are polymorphic, so we use this model as the subscriber record.
-
class NewsletterSubscriber < ApplicationRecord
-
has_many :mailkick_subscriptions, as: :subscriber, class_name: "Mailkick::Subscription", dependent: :destroy
-
-
normalizes :email, with: ->(e) { e.strip.downcase }
-
-
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
-
end
-
# frozen_string_literal: true
-
-
# Opportunity model for tracking recruiter outreach emails
-
# Captures job opportunities from emails before user decides to apply
-
#
-
# @example
-
# opportunity = Opportunity.create!(user: user, company_name: "Stripe")
-
# opportunity.apply! # Transitions to applied state
-
#
-
class Opportunity < ApplicationRecord
-
include Transitionable
-
-
# Status values stored as strings for readability
-
STATUSES = %i[new reviewing applied archived].freeze
-
SOURCE_TYPES = %w[direct_email linkedin_forward referral other].freeze
-
-
# Associations
-
belongs_to :user
-
belongs_to :synced_email, optional: true
-
belongs_to :interview_application, optional: true
-
belongs_to :job_listing, optional: true
-
has_one :saved_job, dependent: :destroy
-
has_one :fit_assessment, as: :fittable, dependent: :destroy
-
-
# Validations
-
validates :user, presence: true
-
validates :source_type, inclusion: { in: SOURCE_TYPES }, allow_nil: true
-
-
# Store accessors for extracted_data
-
store_accessor :extracted_data,
-
:is_forwarded,
-
:original_source,
-
:raw_extraction
-
-
# AASM state machine for status
-
aasm column: :status, with_klass: BaseAasm do
-
requires_guards!
-
log_transitions!
-
-
state :new, initial: true
-
state :reviewing
-
state :applied
-
state :archived
-
-
event :start_review do
-
transitions from: :new, to: :reviewing
-
end
-
-
event :mark_applied do
-
transitions from: [ :new, :reviewing ], to: :applied
-
end
-
-
event :archive_as_ignored do
-
transitions from: [ :new, :reviewing ], to: :archived, after: :set_archived_as_ignored
-
end
-
-
event :reconsider do
-
transitions from: :archived, to: :new, after: :clear_archived_metadata
-
end
-
end
-
-
# Scopes
-
scope :actionable, -> { where(status: %w[new reviewing]) }
-
scope :archived, -> { where(status: "archived") }
-
scope :recent, -> { order(created_at: :desc) }
-
scope :by_status, ->(status) { where(status: status) }
-
scope :with_job_url, -> { where.not(job_url: [ nil, "" ]) }
-
scope :without_job_url, -> { where(job_url: [ nil, "" ]) }
-
-
# Returns the display title for this opportunity
-
#
-
# @return [String] Title combining role and company
-
def display_title
-
parts = []
-
parts << job_role_title if job_role_title.present?
-
parts << "at #{company_name}" if company_name.present?
-
parts.join(" ") || "New Opportunity"
-
end
-
-
# Returns the recruiter display name
-
#
-
# @return [String, nil] Recruiter name or email
-
def recruiter_display
-
recruiter_name.presence || recruiter_email
-
end
-
-
# Checks if this opportunity has a job URL
-
#
-
# @return [Boolean] True if job_url is present
-
def has_job_url?
-
job_url.present?
-
end
-
-
# Checks if this opportunity has extracted links
-
#
-
# @return [Boolean] True if extracted_links is not empty
-
def has_extracted_links?
-
extracted_links.present? && extracted_links.any?
-
end
-
-
# Returns extracted links as structured objects
-
#
-
# @return [Array<Hash>] Array of link hashes with url, type, description
-
def parsed_links
-
return [] unless extracted_links.is_a?(Array)
-
-
extracted_links.map do |link|
-
if link.is_a?(Hash)
-
link.symbolize_keys
-
else
-
{ url: link.to_s, type: "unknown", description: nil }
-
end
-
end
-
end
-
-
# Returns the primary job link (first job-related link found)
-
#
-
# @return [String, nil] The primary job URL
-
def primary_job_link
-
return job_url if job_url.present?
-
-
job_link = parsed_links.find { |l| l[:type] == "job_posting" }
-
job_link&.dig(:url)
-
end
-
-
# Checks if this is a forwarded email (e.g., from LinkedIn)
-
#
-
# @return [Boolean] True if the email was forwarded
-
def forwarded?
-
is_forwarded == true || source_type == "linkedin_forward"
-
end
-
-
# Returns badge classes for the status
-
#
-
# @return [String] Tailwind CSS classes
-
def status_badge_classes
-
case status
-
when "new"
-
"bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
-
when "reviewing"
-
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300"
-
when "applied"
-
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"
-
when "archived"
-
"bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
-
else
-
"bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
-
end
-
end
-
-
# Returns icon name for source type
-
#
-
# @return [String] Icon identifier
-
def source_type_icon
-
case source_type
-
when "linkedin_forward"
-
"linkedin"
-
when "referral"
-
"users"
-
when "direct_email"
-
"mail"
-
else
-
"mail"
-
end
-
end
-
-
# Returns human-readable source type
-
#
-
# @return [String] Display name
-
def source_type_display
-
case source_type
-
when "linkedin_forward"
-
"LinkedIn"
-
when "referral"
-
"Referral"
-
when "direct_email"
-
"Direct Email"
-
else
-
"Email"
-
end
-
end
-
-
# Returns a short snippet for display
-
#
-
# @param length [Integer] Maximum length
-
# @return [String] Truncated key details or email snippet
-
def short_description(length = 100)
-
text = key_details.presence || email_snippet
-
text&.truncate(length) || ""
-
end
-
-
private
-
-
# @return [void]
-
def set_archived_as_ignored
-
update_columns(
-
archived_reason: "ignored",
-
archived_at: Time.current
-
)
-
end
-
-
# @return [void]
-
def clear_archived_metadata
-
update_columns(
-
archived_reason: nil,
-
archived_at: nil
-
)
-
end
-
end
-
# frozen_string_literal: true
-
-
# ResumeSkill model representing extracted skills from a specific resume
-
#
-
# Links a UserResume to a SkillTag with proficiency levels and evidence
-
#
-
# @example
-
# resume_skill = ResumeSkill.create!(
-
# user_resume: resume,
-
# skill_tag: skill,
-
# model_level: 4,
-
# confidence_score: 0.85,
-
# category: "Backend",
-
# evidence_snippet: "5 years of Ruby on Rails experience"
-
# )
-
#
-
class ResumeSkill < ApplicationRecord
-
# Constants
-
PROFICIENCY_LEVELS = (1..5).to_a.freeze
-
CATEGORIES = %w[
-
Backend
-
Frontend
-
Fullstack
-
Infrastructure
-
DevOps
-
Data
-
Mobile
-
Leadership
-
Communication
-
ProjectManagement
-
Design
-
Security
-
AI/ML
-
Other
-
].freeze
-
-
# Associations
-
belongs_to :user_resume
-
belongs_to :skill_tag
-
-
# Delegations
-
delegate :user, to: :user_resume
-
delegate :name, to: :skill_tag, prefix: :skill
-
-
# Validations
-
validates :model_level, presence: true, inclusion: { in: PROFICIENCY_LEVELS }
-
validates :user_level, inclusion: { in: PROFICIENCY_LEVELS }, allow_nil: true
-
validates :confidence_score, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1 }, allow_nil: true
-
validates :user_resume_id, uniqueness: { scope: :skill_tag_id, message: "skill already exists for this resume" }
-
-
# Scopes
-
scope :by_category, ->(category) { where(category: category) }
-
scope :high_confidence, -> { where("confidence_score >= ?", 0.7) }
-
scope :user_confirmed, -> { where.not(user_level: nil) }
-
scope :alphabetical, -> { joins(:skill_tag).order("skill_tags.name ASC") }
-
scope :by_proficiency, -> { order(Arel.sql("COALESCE(user_level, model_level) DESC")) }
-
-
# Callbacks
-
after_save :trigger_skill_aggregation
-
after_destroy :trigger_skill_aggregation
-
-
# Returns the effective proficiency level (user override or AI-assigned)
-
#
-
# @return [Integer] Proficiency level 1-5
-
def effective_level
-
user_level || model_level
-
end
-
-
# Checks if user has confirmed/adjusted this skill
-
#
-
# @return [Boolean]
-
def user_confirmed?
-
user_level.present?
-
end
-
-
# Sets the user-confirmed proficiency level
-
#
-
# @param level [Integer] Proficiency level 1-5
-
# @return [Boolean]
-
def confirm_level!(level)
-
update!(user_level: level)
-
end
-
-
# Returns confidence as a percentage
-
#
-
# @return [Integer] Confidence percentage 0-100
-
def confidence_percentage
-
return 0 unless confidence_score
-
-
(confidence_score * 100).round
-
end
-
-
# Returns a human-readable proficiency label
-
#
-
# @return [String] Proficiency description
-
def proficiency_label
-
case effective_level
-
when 1 then "Beginner"
-
when 2 then "Elementary"
-
when 3 then "Intermediate"
-
when 4 then "Advanced"
-
when 5 then "Expert"
-
else "Unknown"
-
end
-
end
-
-
private
-
-
# Triggers user skill aggregation after changes
-
def trigger_skill_aggregation
-
Resumes::SkillAggregationService.new(user).aggregate_skill(skill_tag)
-
rescue => e
-
Rails.logger.error("Failed to aggregate skill: #{e.message}")
-
end
-
end
-
# frozen_string_literal: true
-
-
# ResumeWorkExperience represents one work experience extracted from a specific resume.
-
# It stores rich work history (dates, responsibilities, highlights) and can be linked
-
# to canonical Company/JobRole records when possible.
-
class ResumeWorkExperience < ApplicationRecord
-
belongs_to :user_resume
-
belongs_to :company, optional: true
-
belongs_to :job_role, optional: true
-
-
has_many :resume_work_experience_skills, dependent: :destroy
-
has_many :skill_tags, through: :resume_work_experience_skills
-
-
scope :chronological, -> { order(Arel.sql("COALESCE(start_date, end_date) ASC NULLS LAST"), created_at: :asc) }
-
scope :reverse_chronological, -> { order(Arel.sql("COALESCE(end_date, start_date) DESC NULLS LAST"), created_at: :desc) }
-
-
# @return [String]
-
def display_company_name
-
company&.name.presence || company_name.to_s
-
end
-
-
# @return [String]
-
def display_role_title
-
job_role&.title.presence || role_title.to_s
-
end
-
end
-
# frozen_string_literal: true
-
-
# Join model linking a ResumeWorkExperience to a SkillTag (skills used in that role).
-
class ResumeWorkExperienceSkill < ApplicationRecord
-
belongs_to :resume_work_experience
-
belongs_to :skill_tag
-
-
validates :resume_work_experience_id, uniqueness: { scope: :skill_tag_id }
-
validates :confidence_score, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1 }, allow_nil: true
-
end
-
# frozen_string_literal: true
-
-
# SavedJob model representing a user-saved job lead.
-
#
-
# A saved job can be created from:
-
# - an existing Opportunity (email-sourced lead), or
-
# - a pasted URL (new job lead).
-
#
-
# Exactly one of `opportunity_id` or `url` must be present.
-
#
-
# @example Save from an opportunity
-
# SavedJob.create!(user: user, opportunity: opportunity)
-
#
-
# @example Save from a URL
-
# SavedJob.create!(user: user, url: "https://boards.greenhouse.io/acme/jobs/123")
-
#
-
class SavedJob < ApplicationRecord
-
include Transitionable
-
-
belongs_to :user
-
belongs_to :opportunity, optional: true
-
-
has_one :fit_assessment, as: :fittable, dependent: :destroy
-
-
validates :user, presence: true
-
validate :exactly_one_source
-
validate :valid_url_format, if: -> { url.present? }
-
-
aasm column: :status, with_klass: BaseAasm do
-
requires_guards!
-
log_transitions!
-
-
state :active, initial: true
-
state :archived
-
-
event :archive_removed do
-
transitions from: :active, to: :archived, after: :set_archived_as_removed
-
end
-
-
event :restore do
-
transitions from: :archived, to: :active, after: :clear_archived_metadata
-
end
-
end
-
-
scope :recent, -> { order(created_at: :desc) }
-
scope :active, -> { where(status: "active") }
-
scope :archived, -> { where(status: "archived") }
-
scope :converted, -> { where.not(converted_at: nil) }
-
scope :unconverted, -> { where(converted_at: nil) }
-
-
# Returns the best URL for conversion or display.
-
#
-
# @return [String, nil]
-
def effective_url
-
url.presence || opportunity&.primary_job_link
-
end
-
-
private
-
-
# @return [void]
-
def set_archived_as_removed
-
update_columns(
-
archived_reason: "removed_saved_job",
-
archived_at: Time.current
-
)
-
end
-
-
# @return [void]
-
def clear_archived_metadata
-
update_columns(
-
archived_reason: nil,
-
archived_at: nil
-
)
-
end
-
-
def exactly_one_source
-
if opportunity_id.present? && url.present?
-
errors.add(:base, "Saved job must have either an opportunity or a URL, not both")
-
elsif opportunity_id.blank? && url.blank?
-
errors.add(:base, "Saved job must have an opportunity or a URL")
-
end
-
end
-
-
def valid_url_format
-
uri = URI.parse(url)
-
return if uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
-
-
errors.add(:url, "must be a valid HTTP/HTTPS URL")
-
rescue URI::InvalidURIError
-
errors.add(:url, "must be a valid HTTP/HTTPS URL")
-
end
-
end
-
# frozen_string_literal: true
-
-
require "digest"
-
require "rack/utils"
-
-
# ScrapedJobListingData model for caching HTML content with validity periods
-
#
-
# Stores fetched HTML content to avoid repeated network requests and enable
-
# idempotent retries of extraction steps.
-
class ScrapedJobListingData < ApplicationRecord
-
VALIDITY_PERIOD_DAYS = 30
-
TRACKING_QUERY_KEYS = %w[
-
utm_source utm_medium utm_campaign utm_term utm_content
-
gclid fbclid msclkid
-
gh_src ccuid
-
].freeze
-
-
belongs_to :job_listing
-
belongs_to :scraping_attempt, optional: true
-
-
validates :url, presence: true
-
validates :valid_until, presence: true
-
validates :content_hash, uniqueness: { scope: [ :url, :job_listing_id ] }, allow_nil: true
-
-
scope :valid, -> { where("valid_until > ?", Time.current) }
-
scope :expired, -> { where("valid_until <= ?", Time.current) }
-
scope :for_url, ->(url) { where(url: normalize_url(url)) }
-
-
# Finds or creates a valid cache entry for a URL
-
#
-
# @param [String] url The job listing URL
-
# @param [JobListing] job_listing The job listing
-
# @return [ScrapedJobListingData, nil] Cache entry or nil
-
def self.find_valid_for_url(url, job_listing: nil)
-
normalized_url = normalize_url(url)
-
valid.for_url(normalized_url)
-
.where(job_listing: job_listing)
-
.order(valid_until: :desc)
-
.first
-
end
-
-
# Creates a new cache entry with HTML content
-
#
-
# @param [String] url The job listing URL
-
# @param [String] html_content The HTML content
-
# @param [JobListing] job_listing The job listing
-
# @param [ScrapingAttempt] scraping_attempt Optional scraping attempt
-
# @param [Hash] metadata Additional fetch metadata
-
# @return [ScrapedJobListingData] The created cache entry
-
def self.create_with_html(url:, html_content:, job_listing:, scraping_attempt: nil, http_status: nil, metadata: {})
-
normalized_url = normalize_url(url)
-
content_hash = Digest::SHA256.hexdigest(html_content)
-
cleaned_html = clean_html(html_content, url: url)
-
-
record = find_or_initialize_by(job_listing: job_listing, url: normalized_url, content_hash: content_hash)
-
record.html_content = html_content
-
record.cleaned_html = cleaned_html
-
record.http_status = http_status
-
record.valid_until = VALIDITY_PERIOD_DAYS.days.from_now
-
record.fetch_metadata = (record.fetch_metadata || {}).merge(metadata || {})
-
record.scraping_attempt ||= scraping_attempt
-
record.save!
-
record
-
end
-
-
# Normalizes URL for consistent lookup
-
#
-
# @param [String] url The URL to normalize
-
# @return [String] Normalized URL
-
def self.normalize_url(url)
-
uri = URI.parse(url)
-
-
# Use canonical URL for boards with special URL formats (e.g., LinkedIn)
-
detector = Scraping::JobBoardDetectorService.new(url)
-
if detector.detect == :linkedin
-
return detector.canonical_url.downcase
-
end
-
-
params = Rack::Utils.parse_query(uri.query.to_s)
-
-
# Drop common marketing/tracking params, but keep params that define the resource
-
# (e.g. Greenhouse `gh_jid`).
-
params = params.reject { |k, _| TRACKING_QUERY_KEYS.include?(k.to_s) || k.to_s.start_with?("utm_") }
-
-
normalized = +"#{uri.scheme}://#{uri.host}#{uri.path}"
-
if params.any?
-
normalized << "?" << URI.encode_www_form(params.sort_by { |k, _| k.to_s })
-
end
-
-
normalized.downcase
-
rescue URI::InvalidURIError
-
url.to_s.downcase
-
end
-
-
# Cleans HTML content for better extraction
-
#
-
# Uses board-specific cleaners when URL is known, otherwise falls back to generic.
-
#
-
# @param [String] html The raw HTML
-
# @param [String, nil] url Optional URL to determine board-specific cleaner
-
# @return [String] Cleaned HTML text
-
def self.clean_html(html, url: nil)
-
return "" if html.blank?
-
-
cleaner = if url.present?
-
Scraping::HtmlCleaners::CleanerFactory.cleaner_for_url(url)
-
else
-
Scraping::NokogiriHtmlCleanerService.new
-
end
-
cleaner.clean(html)
-
end
-
-
# Checks if this cache entry is still valid (not expired)
-
#
-
# @return [Boolean] True if valid
-
def cache_valid?
-
valid_until > Time.current
-
end
-
-
# Checks if this cache entry has expired
-
#
-
# @return [Boolean] True if expired
-
def expired?
-
!cache_valid?
-
end
-
-
# Marks this cache entry as expired
-
#
-
# @return [Boolean] True if updated
-
def expire!
-
update(valid_until: Time.current - 1.second)
-
end
-
-
# Returns the HTML content to use (cleaned if available, otherwise raw)
-
#
-
# @return [String] HTML content
-
def html_for_extraction
-
cleaned_html.presence || html_content
-
end
-
end
-
# frozen_string_literal: true
-
-
# ScrapingAttempt model representing a job listing extraction attempt
-
1
class ScrapingAttempt < ApplicationRecord
-
1
include Transitionable
-
-
1
STATUSES = [
-
:pending, # Initial state, queued
-
:fetching, # Downloading HTML/API data
-
:extracting, # Processing with LLM/parser
-
:completed, # Successfully extracted
-
:failed, # Failed, will retry
-
:retrying, # In retry queue
-
:dead_letter, # Exhausted retries, needs admin
-
:manual # Admin manually fixed
-
].freeze
-
-
1
EXTRACTION_METHODS = [ :api, :ai, :html ].freeze
-
-
1
belongs_to :job_listing
-
1
has_one :scraped_job_listing_data, dependent: :nullify
-
1
has_many :llm_api_logs, class_name: "Ai::LlmApiLog", as: :loggable, dependent: :destroy
-
1
has_many :scraping_events, dependent: :destroy
-
1
has_many :html_scraping_logs, dependent: :destroy
-
-
# Define enum for status to map integer values to state names
-
1
enum :status, {
-
pending: 0,
-
fetching: 1,
-
extracting: 2,
-
completed: 3,
-
failed: 4,
-
retrying: 5,
-
dead_letter: 6,
-
manual: 7
-
}, default: :pending
-
-
1
validates :url, presence: true
-
1
validates :domain, presence: true
-
1
validates :extraction_method, inclusion: { in: EXTRACTION_METHODS.map(&:to_s) }, allow_nil: true
-
-
1
scope :recent, -> { order(created_at: :desc) }
-
1
scope :by_domain, ->(domain) { where(domain: domain) }
-
1
scope :by_status, ->(status) { where(status: status) }
-
1
scope :needs_review, -> { where(status: [ :dead_letter, :failed ]) }
-
1
scope :recent_period, ->(days = 7) { where("created_at > ?", days.days.ago) }
-
-
# Status state machine
-
1
aasm column: :status, enum: true, with_klass: BaseAasm do
-
1
requires_guards!
-
1
log_transitions!
-
-
1
state :pending, initial: true
-
1
state :fetching
-
1
state :extracting
-
1
state :completed
-
1
state :failed
-
1
state :retrying
-
1
state :dead_letter
-
1
state :manual
-
-
1
event :start_fetch do
-
1
transitions from: [ :pending, :retrying ], to: :fetching
-
end
-
-
1
event :start_extract do
-
1
transitions from: [ :fetching, :retrying ], to: :extracting
-
end
-
-
1
event :mark_completed do
-
1
transitions from: :extracting, to: :completed
-
end
-
-
1
event :mark_failed do
-
1
transitions from: [ :fetching, :extracting, :retrying ], to: :failed
-
end
-
-
1
event :retry_attempt do
-
1
transitions from: :failed, to: :retrying
-
end
-
-
1
event :send_to_dlq do
-
1
transitions from: :failed, to: :dead_letter
-
end
-
-
1
event :mark_manual do
-
1
transitions from: [ :dead_letter, :failed ], to: :manual
-
end
-
end
-
-
# Returns badge color for status
-
# @return [String] Color name for badge
-
1
def status_badge_color
-
when: 0
case status.to_sym
-
when: 0
when :completed then "success"
-
when: 0
when :pending, :fetching, :extracting, :retrying then "info"
-
when: 0
when :failed then "danger"
-
when: 0
when :dead_letter then "warning"
-
else: 0
when :manual then "neutral"
-
else "neutral"
-
end
-
end
-
-
# Checks if this attempt needs admin review
-
# @return [Boolean] True if needs review
-
1
def needs_review?
-
dead_letter? || (failed? && retry_count >= 3)
-
end
-
-
# Checks if HTML fetch step failed
-
# @return [Boolean] True if HTML fetch failed
-
1
def html_fetch_failed?
-
failed_step == "html_fetch"
-
end
-
-
# Checks if API extraction step failed
-
# @return [Boolean] True if API extraction failed
-
1
def api_extraction_failed?
-
failed_step == "api_extraction"
-
end
-
-
# Checks if AI extraction step failed
-
# @return [Boolean] True if AI extraction failed
-
1
def ai_extraction_failed?
-
failed_step == "ai_extraction"
-
end
-
-
# Returns cached HTML data if available
-
# @return [ScrapedJobListingData, nil] Cached HTML data or nil
-
1
def cached_html_data
-
scraped_job_listing_data || ScrapedJobListingData.find_valid_for_url(url, job_listing: job_listing)
-
end
-
-
# Convenience accessor for the most recent HTML scraping log
-
#
-
# @return [HtmlScrapingLog, nil]
-
1
def latest_html_scraping_log
-
html_scraping_logs.order(created_at: :desc).first
-
end
-
-
# Returns formatted duration
-
# @return [String, nil] Formatted duration
-
1
def formatted_duration
-
then: 0
else: 0
return nil if duration_seconds.nil?
-
-
then: 0
if duration_seconds < 1
-
else: 0
"#{(duration_seconds * 1000).round}ms"
-
then: 0
elsif duration_seconds < 60
-
"#{duration_seconds.round(2)}s"
-
else: 0
else
-
minutes = (duration_seconds / 60).floor
-
seconds = (duration_seconds % 60).round
-
"#{minutes}m #{seconds}s"
-
end
-
end
-
-
# Returns success rate for this domain
-
# @return [Float] Success rate as percentage
-
1
def self.success_rate_for_domain(domain, days = 7)
-
attempts = by_domain(domain).recent_period(days)
-
then: 0
else: 0
return 0.0 if attempts.count.zero?
-
-
completed = attempts.where(status: :completed).count
-
(completed.to_f / attempts.count * 100).round(1)
-
end
-
end
-
# frozen_string_literal: true
-
-
# ScrapingEvent model for tracking individual steps in the scraping pipeline
-
#
-
# Records each step of the extraction process (fetch, parse, extract) with
-
# timing, payloads, and status for complete observability.
-
#
-
# @example
-
# event = ScrapingEvent.create!(
-
# scraping_attempt: attempt,
-
# event_type: :html_fetch,
-
# step_order: 1,
-
# status: :success,
-
# duration_ms: 1500
-
# )
-
class ScrapingEvent < ApplicationRecord
-
EVENT_TYPES = [
-
:permission_check, # Robots.txt / rate limit check
-
:job_board_detection, # Job board/ATS detection from URL
-
:html_fetch, # Fetching HTML content
-
:embedded_job_board_fetch, # Fetch embedded job board HTML (e.g., Greenhouse embeds)
-
:js_heavy_detected, # Heuristic indicating JS-rendered content
-
:rendered_html_fetch, # Selenium/Headless rendered HTML fetch
-
:limited_source_handling, # Handling limited extraction sources (LinkedIn, etc.)
-
:nokogiri_scrape, # Preliminary HTML parsing
-
:selectors_extraction, # Selectors-first extraction (job boards)
-
:api_extraction, # API-based extraction (Greenhouse, Lever)
-
:ai_extraction, # LLM-based extraction
-
:data_update, # Updating job listing with extracted data
-
:completion, # Successful completion
-
:failure # Pipeline failure
-
].freeze
-
-
STATUSES = [
-
:started, # Step has begun
-
:success, # Step completed successfully
-
:failed, # Step failed
-
:skipped # Step was skipped
-
].freeze
-
-
# Associations
-
belongs_to :scraping_attempt
-
belongs_to :job_listing, optional: true
-
-
# Enums
-
enum :event_type, {
-
permission_check: "permission_check",
-
job_board_detection: "job_board_detection",
-
html_fetch: "html_fetch",
-
embedded_job_board_fetch: "embedded_job_board_fetch",
-
js_heavy_detected: "js_heavy_detected",
-
rendered_html_fetch: "rendered_html_fetch",
-
limited_source_handling: "limited_source_handling",
-
nokogiri_scrape: "nokogiri_scrape",
-
selectors_extraction: "selectors_extraction",
-
api_extraction: "api_extraction",
-
ai_extraction: "ai_extraction",
-
data_update: "data_update",
-
completion: "completion",
-
failure: "failure"
-
}
-
-
enum :status, {
-
started: 0,
-
success: 1,
-
failed: 2,
-
skipped: 3
-
}, default: :started
-
-
# Validations
-
validates :event_type, presence: true
-
-
# Scopes
-
scope :for_attempt, ->(attempt_id) { where(scraping_attempt_id: attempt_id) }
-
scope :by_type, ->(type) { where(event_type: type) }
-
scope :successful, -> { where(status: :success) }
-
scope :failed, -> { where(status: :failed) }
-
scope :in_order, -> { order(step_order: :asc, created_at: :asc) }
-
scope :recent, -> { order(created_at: :desc) }
-
-
# Returns formatted duration string
-
#
-
# @return [String] Formatted duration (e.g., "1.5s", "250ms")
-
def formatted_duration
-
return "N/A" if duration_ms.nil?
-
-
if duration_ms < 1000
-
"#{duration_ms}ms"
-
else
-
"#{(duration_ms / 1000.0).round(2)}s"
-
end
-
end
-
-
# Returns a summary of the input payload
-
#
-
# @param [Integer] max_length Maximum length of summary
-
# @return [String] Summary of input
-
def input_summary(max_length = 100)
-
return "No input" if input_payload.blank?
-
-
summarize_payload(input_payload, max_length)
-
end
-
-
# Returns a summary of the output payload
-
#
-
# @param [Integer] max_length Maximum length of summary
-
# @return [String] Summary of output
-
def output_summary(max_length = 100)
-
return "No output" if output_payload.blank?
-
-
summarize_payload(output_payload, max_length)
-
end
-
-
# Returns the event type display name
-
#
-
# @return [String] Human-readable event type
-
def event_type_display
-
case event_type&.to_sym
-
when :permission_check then "Permission Check"
-
when :job_board_detection then "Job Board Detection"
-
when :html_fetch then "HTML Fetch"
-
when :embedded_job_board_fetch then "Embedded Job Board Fetch"
-
when :js_heavy_detected then "JS-Heavy Detected"
-
when :rendered_html_fetch then "Rendered HTML Fetch"
-
when :limited_source_handling then "Limited Source Handling"
-
when :nokogiri_scrape then "HTML Parse"
-
when :selectors_extraction then "Selectors Extraction"
-
when :api_extraction then "API Extraction"
-
when :ai_extraction then "AI Extraction"
-
when :data_update then "Data Update"
-
when :completion then "Completion"
-
when :failure then "Failure"
-
else event_type&.titleize || "Unknown"
-
end
-
end
-
-
# Returns icon name for the event type
-
#
-
# @return [String] Icon identifier
-
def event_icon
-
case event_type&.to_sym
-
when :permission_check then "shield-check"
-
when :job_board_detection then "tag"
-
when :html_fetch then "cloud-download"
-
when :embedded_job_board_fetch then "link"
-
when :js_heavy_detected then "sparkles"
-
when :rendered_html_fetch then "globe-alt"
-
when :limited_source_handling then "exclamation-triangle"
-
when :nokogiri_scrape then "code"
-
when :selectors_extraction then "adjustments-horizontal"
-
when :api_extraction then "server"
-
when :ai_extraction then "cpu"
-
when :data_update then "database"
-
when :completion then "check-circle"
-
when :failure then "x-circle"
-
else "circle"
-
end
-
end
-
-
# Returns status badge color
-
#
-
# @return [String] Color class name
-
def status_badge_color
-
case status&.to_sym
-
when :success then "success"
-
when :failed then "danger"
-
when :skipped then "neutral"
-
when :started then "info"
-
else "neutral"
-
end
-
end
-
-
# Checks if this event has error details
-
#
-
# @return [Boolean] True if has error
-
def has_error?
-
error_message.present? || error_type.present?
-
end
-
-
# Returns extracted fields from output payload
-
#
-
# @return [Array<String>] List of field names
-
def extracted_fields
-
return [] unless output_payload.is_a?(Hash)
-
-
output_payload["extracted_fields"] || output_payload.keys.select do |k|
-
!%w[error confidence raw_response].include?(k)
-
end
-
end
-
-
private
-
-
# Summarizes a payload hash for display
-
#
-
# @param [Hash] payload The payload to summarize
-
# @param [Integer] max_length Maximum length
-
# @return [String] Summary
-
def summarize_payload(payload, max_length)
-
return payload.to_s.truncate(max_length) unless payload.is_a?(Hash)
-
-
keys = payload.keys.first(5)
-
summary = keys.map { |k| "#{k}: #{payload[k].to_s.truncate(20)}" }.join(", ")
-
-
if payload.keys.length > 5
-
summary += " (+#{payload.keys.length - 5} more)"
-
end
-
-
summary.truncate(max_length)
-
end
-
end
-
class Session < ApplicationRecord
-
belongs_to :user
-
end
-
1
class Setting < ApplicationRecord
-
AVAILABLE_SETTINGS = %w[
-
1
user_sign_up_enabled
-
user_login_enabled
-
user_email_verification_enabled
-
username_password_login_enabled
-
magic_link_login_enabled
-
oauth_login_enabled
-
oauth_registration_enabled
-
google_login_enabled
-
google_registration_enabled
-
analytics_enabled
-
mixpanel_enabled
-
sentry_enabled
-
bugsnag_enabled
-
api_population_enabled
-
ashby_enabled
-
greenhouse_enabled
-
lever_enabled
-
linkedin_enabled
-
indeed_enabled
-
glassdoor_enabled
-
ziprecruiter_enabled
-
careerbuilder_enabled
-
monster_enabled
-
careerjet_enabled
-
js_rendering_enabled
-
turnstile_enabled
-
helicone_enabled
-
signals_decision_shadow_enabled
-
signals_decision_execution_enabled
-
signals_email_facts_extraction_enabled
-
signals_decision_opportunity_creation_enabled
-
]
-
-
1
CACHE_KEY = "settings"
-
1
CACHE_TTL = ENV.fetch("CACHE_TTL", 15.seconds).to_i
-
-
1
SKIP_MUTEX = ENV.fetch("SETTINGS_SKIP_MUTEX", false).to_s == "true"
-
1
MUTEX = Mutex.new
-
-
1
validates :name, presence: true, uniqueness: { case_sensitive: true }, format: { with: /\A[a-zA-Z0-9_]+\z/, message: "can only contain letters, numbers, and underscores" }
-
-
# after_save :purge_table_key
-
1
after_commit :purge_cache
-
-
1
AVAILABLE_SETTINGS.each do |setting|
-
31
define_singleton_method(:"#{setting}?") do
-
(cached_settings || {})[setting.to_s] == true
-
end
-
end
-
-
1
def purge_cache
-
66
self.class.purge_cache
-
end
-
-
1
class << self
-
1
attr_accessor :disabled_cached_settings
-
-
1
def toggle(name)
-
else: 0
then: 0
raise "Setting unknown: #{name}" unless AVAILABLE_SETTINGS.include?(name)
-
setting = where(name: name).first_or_create
-
setting.value = !setting.value
-
setting.save!
-
setting.value
-
end
-
-
1
def set(name:, value:)
-
33
else: 33
then: 0
raise "Setting unknown: #{name}" unless AVAILABLE_SETTINGS.include?(name)
-
33
setting = where(name: name).first_or_create
-
33
setting.value = value
-
33
setting.save!
-
33
setting.value
-
end
-
-
1
def enable!(name)
-
set(name: name.to_s, value: true)
-
end
-
-
1
def disable!(name)
-
set(name: name, value: false)
-
end
-
-
1
def purge_cache
-
66
then: 0
else: 66
remove_instance_variable(:@cached_settings) if defined?(@cached_settings)
-
66
Rails.cache.delete("#{CACHE_KEY}")
-
end
-
-
1
def cached_settings
-
synchronize do
-
else: 0
then: 0
return @cached_settings unless expired?
-
-
value = Rails.cache.fetch(CACHE_KEY) do
-
settings = Setting.all
-
settings.each_with_object({}) do |setting, hash|
-
hash[setting.name] = setting.value
-
end
-
end
-
@cached_settings = value || {} # Ensure we always return a hash, never nil
-
@cached_settings_expires_at = Time.current.to_i + CACHE_TTL
-
-
@cached_settings
-
end
-
end
-
-
1
def expired?
-
else: 0
then: 0
return true unless defined?(@cached_settings_expires_at)
-
then: 0
else: 0
return true if @cached_settings_expires_at.nil?
-
then: 0
else: 0
return true if @cached_settings_expires_at < Time.current.to_i
-
-
@cached_settings_expires_at <= Time.current.to_i + CACHE_TTL
-
end
-
-
1
def synchronize(&block)
-
then: 0
else: 0
return yield if SKIP_MUTEX
-
-
MUTEX.synchronize(&block)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
# Represents a single step/event within an EmailPipelineRun.
-
class EmailPipelineEvent < ApplicationRecord
-
EVENT_TYPES = [
-
:synced_email_upsert,
-
:email_classification,
-
:company_detection,
-
:application_match,
-
:signal_extraction_enqueued,
-
-
:legacy_signal_extraction,
-
:email_facts_extraction,
-
-
:decision_input_build,
-
:decision_plan_build,
-
:decision_plan_schema_validate,
-
:decision_plan_semantic_validate,
-
-
:execution_dispatch,
-
-
:execute_set_pipeline_stage,
-
:execute_set_application_status,
-
:execute_create_round,
-
:execute_update_round,
-
:execute_set_round_result,
-
:execute_create_interview_feedback,
-
:execute_create_company_feedback,
-
:execute_create_opportunity,
-
:execute_upsert_job_listing_from_url,
-
:execute_attach_job_listing_to_opportunity,
-
:execute_enqueue_scrape_job_listing,
-
-
:legacy_orchestrator
-
].freeze
-
-
STATUSES = %i[started success failed skipped].freeze
-
-
belongs_to :run, class_name: "Signals::EmailPipelineRun", inverse_of: :events
-
belongs_to :synced_email
-
belongs_to :interview_application, optional: true
-
-
enum :event_type, EVENT_TYPES.index_with(&:to_s)
-
enum :status, {
-
started: 0,
-
success: 1,
-
failed: 2,
-
skipped: 3
-
}, default: :started
-
-
validates :event_type, presence: true
-
validates :status, presence: true
-
validates :step_order, presence: true
-
-
scope :recent, -> { order(created_at: :desc) }
-
scope :in_order, -> { order(step_order: :asc, created_at: :asc) }
-
scope :by_type, ->(type) { where(event_type: type) }
-
scope :by_status, ->(status) { where(status: status) }
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
# Represents a single end-to-end processing run for a SyncedEmail.
-
#
-
# Mirrors the ScrapingAttempt/ScrapingEvent pattern, but for the email → signals pipeline.
-
class EmailPipelineRun < ApplicationRecord
-
STATUSES = %i[started success failed].freeze
-
-
belongs_to :synced_email
-
belongs_to :user
-
belongs_to :connected_account
-
-
has_many :events,
-
class_name: "Signals::EmailPipelineEvent",
-
foreign_key: :run_id,
-
inverse_of: :run,
-
dependent: :destroy
-
-
enum :status, {
-
started: 0,
-
success: 1,
-
failed: 2
-
}, default: :started
-
-
validates :status, presence: true
-
validates :trigger, presence: true
-
validates :mode, presence: true
-
validates :started_at, presence: true
-
-
scope :recent, -> { order(created_at: :desc) }
-
scope :by_status, ->(status) { where(status: status) }
-
-
def next_step_order
-
events.maximum(:step_order).to_i + 1
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# SkillTag model representing skills tracked across interviews and resumes
-
class SkillTag < ApplicationRecord
-
include Disableable
-
extend FriendlyId
-
friendly_id :name, use: [ :slugged, :finders ]
-
-
# def should_generate_new_friendly_id?
-
# name_changed?
-
# end
-
#
-
-
# Skill name aliases for normalization (maps variations to canonical names)
-
SKILL_ALIASES = {
-
"postgres" => "Postgresql",
-
"postgre" => "Postgresql",
-
"psql" => "Postgresql",
-
"js" => "Javascript",
-
"ts" => "Typescript",
-
"react.js" => "React",
-
"reactjs" => "React",
-
"node.js" => "Node",
-
"nodejs" => "Node",
-
"vue.js" => "Vue",
-
"vuejs" => "Vue",
-
"k8s" => "Kubernetes",
-
"aws" => "Aws",
-
"gcp" => "Google Cloud",
-
"ror" => "Ruby On Rails"
-
}.freeze
-
-
# Interview associations
-
has_many :application_skill_tags, dependent: :destroy
-
has_many :interview_applications, through: :application_skill_tags
-
belongs_to :category, optional: true
-
-
# Resume associations
-
has_many :resume_skills, dependent: :destroy
-
has_many :user_resumes, through: :resume_skills
-
has_many :user_skills, dependent: :destroy
-
has_many :users, through: :user_skills
-
-
validates :name, presence: true, uniqueness: true
-
-
normalizes :name, with: ->(name) { normalize_skill_name(name) }
-
-
scope :by_category, ->(category_id) { where(category_id: category_id) }
-
scope :alphabetical, -> { order(:name) }
-
scope :popular, -> { joins("INNER JOIN interview_skill_tags ON interview_skill_tags.skill_tag_id = skill_tags.id").group("skill_tags.id").order("COUNT(interview_skill_tags.id) DESC") }
-
scope :from_resumes, -> { joins(:resume_skills).distinct }
-
-
def category_name
-
category&.name
-
end
-
-
def legacy_category_name
-
respond_to?(:legacy_category) ? legacy_category : nil
-
end
-
-
# Returns the count of interview applications associated with this skill
-
# @return [Integer] Interview application count
-
def interview_application_count
-
interview_applications.count
-
end
-
-
# Finds or creates a skill tag by name (with alias normalization)
-
# @param name [String] Name of the skill
-
# @return [SkillTag] The skill tag instance
-
def self.find_or_create_by_name(name)
-
normalized = normalize_skill_name(name)
-
find_or_create_by(name: normalized)
-
end
-
-
# Normalizes a skill name, handling aliases
-
# @param name [String] Raw skill name
-
# @return [String] Normalized skill name
-
def self.normalize_skill_name(name)
-
cleaned = name.to_s.strip.downcase
-
canonical = SKILL_ALIASES[cleaned] || cleaned.titleize
-
canonical
-
end
-
-
# Merges duplicate skills into one
-
# @param source_skill [SkillTag] The skill to merge from
-
# @param target_skill [SkillTag] The skill to merge into
-
# Merges a source skill tag into a target skill tag
-
#
-
# @param source_skill [SkillTag] The skill to be merged (will be deleted)
-
# @param target_skill [SkillTag] The skill to merge into
-
# @return [Hash] Result hash with :success, :message/:error keys
-
def self.merge_skills(source_skill, target_skill)
-
if source_skill == target_skill
-
return { success: false, error: "Cannot merge a skill tag into itself." }
-
end
-
-
if source_skill.nil? || target_skill.nil?
-
return { success: false, error: "Source or target skill tag not found." }
-
end
-
-
stats = { resume_skills: 0, user_skills: 0, application_skills: 0 }
-
-
transaction do
-
# Handle duplicate resume_skills by removing them first
-
# Note: resume_skills uses user_resume_id (not resume_id)
-
duplicate_resume_ids = ResumeSkill.where(skill_tag: source_skill)
-
.joins("INNER JOIN resume_skills rs2 ON resume_skills.user_resume_id = rs2.user_resume_id")
-
.where("rs2.skill_tag_id = ?", target_skill.id)
-
.pluck(:id)
-
ResumeSkill.where(id: duplicate_resume_ids).delete_all
-
-
# Update remaining resume_skills
-
stats[:resume_skills] = ResumeSkill.where(skill_tag: source_skill).update_all(skill_tag_id: target_skill.id)
-
-
# Handle duplicate user_skills by removing them first
-
duplicate_user_ids = UserSkill.where(skill_tag: source_skill)
-
.joins("INNER JOIN user_skills us2 ON user_skills.user_id = us2.user_id")
-
.where("us2.skill_tag_id = ?", target_skill.id)
-
.pluck(:id)
-
UserSkill.where(id: duplicate_user_ids).delete_all
-
-
# Update remaining user_skills
-
stats[:user_skills] = UserSkill.where(skill_tag: source_skill).update_all(skill_tag_id: target_skill.id)
-
-
# Handle duplicate interview_skill_tags by removing them first
-
# Note: ApplicationSkillTag maps to interview_skill_tags table with interview_id column
-
duplicate_app_ids = ApplicationSkillTag.where(skill_tag: source_skill)
-
.joins("INNER JOIN interview_skill_tags ist2 ON interview_skill_tags.interview_id = ist2.interview_id")
-
.where("ist2.skill_tag_id = ?", target_skill.id)
-
.pluck(:id)
-
ApplicationSkillTag.where(id: duplicate_app_ids).delete_all
-
-
# Update remaining interview_skill_tags
-
stats[:application_skills] = ApplicationSkillTag.where(skill_tag: source_skill).update_all(skill_tag_id: target_skill.id)
-
-
# Delete the source skill
-
source_skill.destroy!
-
end
-
-
{
-
success: true,
-
message: "Transferred #{stats[:resume_skills]} resume skills, #{stats[:user_skills]} user skills, and #{stats[:application_skills]} application skills."
-
}
-
rescue ActiveRecord::RecordNotUnique => e
-
Rails.logger.error("Merge failed due to duplicate key: #{e.message}")
-
{ success: false, error: "Merge failed: Some records already exist on the target skill. Please try again." }
-
rescue ActiveRecord::RecordNotDestroyed => e
-
Rails.logger.error("Merge failed - could not delete source: #{e.message}")
-
{ success: false, error: "Merge failed: Could not delete the source skill tag. #{e.record.errors.full_messages.join(', ')}" }
-
rescue => e
-
Rails.logger.error("Merge failed: #{e.class} - #{e.message}")
-
{ success: false, error: "Merge failed: #{e.message}" }
-
end
-
end
-
# frozen_string_literal: true
-
-
# SupportTicket model for contact form submissions
-
class SupportTicket < ApplicationRecord
-
belongs_to :user, optional: true
-
-
validates :name, presence: true
-
validates :email, presence: true
-
validates :subject, presence: true
-
validates :message, presence: true
-
validates :status, presence: true
-
-
normalizes :email, with: ->(e) { e.strip.downcase }
-
-
enum :status, {
-
open: "open",
-
in_progress: "in_progress",
-
resolved: "resolved",
-
closed: "closed"
-
}
-
-
scope :recent, -> { order(created_at: :desc) }
-
scope :open_tickets, -> { where(status: [:open, :in_progress]) }
-
scope :by_status, ->(status) { where(status: status) if status.present? }
-
-
# Returns display name for the ticket
-
# @return [String]
-
def display_name
-
"#{name} - #{subject}"
-
end
-
-
# Checks if ticket is from a registered user
-
# @return [Boolean]
-
def from_user?
-
user.present?
-
end
-
-
# Returns a truncated message for display
-
# @param length [Integer] Maximum length
-
# @return [String]
-
def message_preview(length = 100)
-
return message if message.length <= length
-
"#{message[0...length]}..."
-
end
-
end
-
-
# frozen_string_literal: true
-
-
# SyncedEmail model for tracking emails synced from Gmail
-
# Links emails to interview applications and tracks processing status
-
#
-
# Includes AI-powered signal extraction to derive actionable intelligence
-
# from email content (company info, recruiter details, job information).
-
#
-
# @example
-
# email = SyncedEmail.create_from_gmail_message(user, account, message)
-
# email.process!
-
#
-
class SyncedEmail < ApplicationRecord
-
STATUSES = %i[pending processed ignored failed auto_ignored].freeze
-
EMAIL_TYPES = %w[
-
application_confirmation
-
interview_invite
-
interview_reminder
-
round_feedback
-
rejection
-
offer
-
follow_up
-
thank_you
-
scheduling
-
assessment
-
recruiter_outreach
-
other
-
].freeze
-
-
# Types that are interview-related
-
INTERVIEW_TYPES = %w[
-
application_confirmation
-
interview_invite
-
interview_reminder
-
round_feedback
-
rejection
-
offer
-
follow_up
-
scheduling
-
assessment
-
].freeze
-
-
# Types that represent potential opportunities
-
OPPORTUNITY_TYPES = %w[recruiter_outreach interview_invite follow_up].freeze
-
-
# Extraction status values
-
EXTRACTION_STATUSES = %w[pending processing completed failed skipped].freeze
-
-
# Backend actions that require user decision (not automatic)
-
# Note: match_application is handled by the dropdown in the detail panel
-
SUGGESTED_ACTIONS = %w[
-
start_application
-
].freeze
-
-
# Safe CSS properties that can be preserved in email HTML
-
# These are visual properties that don't pose security risks
-
SAFE_STYLE_PROPERTIES = %w[
-
text-align text-decoration color background-color background
-
font-weight font-style font-size line-height font-family
-
padding padding-top padding-bottom padding-left padding-right
-
margin margin-top margin-bottom margin-left margin-right
-
border border-radius border-color border-width border-style
-
border-top border-bottom border-left border-right
-
width max-width min-width height max-height min-height
-
display vertical-align white-space word-wrap overflow
-
table-layout border-collapse border-spacing
-
list-style list-style-type
-
].freeze
-
-
belongs_to :user
-
belongs_to :connected_account
-
belongs_to :interview_application, optional: true
-
belongs_to :email_sender, optional: true
-
has_one :opportunity, dependent: :nullify
-
has_many :email_pipeline_runs,
-
class_name: "Signals::EmailPipelineRun",
-
dependent: :destroy
-
-
# Status enum
-
enum :status, STATUSES, default: :pending
-
-
# Validations
-
validates :gmail_id, presence: true, uniqueness: { scope: :user_id }
-
validates :from_email, presence: true
-
validates :email_type, inclusion: { in: EMAIL_TYPES }, allow_blank: true
-
-
# Normalizations
-
normalizes :from_email, with: ->(email) { email.strip.downcase }
-
-
# Scopes
-
scope :unmatched, -> { where(interview_application_id: nil) }
-
scope :matched, -> { where.not(interview_application_id: nil) }
-
scope :by_type, ->(type) { where(email_type: type) }
-
scope :recent, -> { order(email_date: :desc) }
-
scope :chronological, -> { order(email_date: :asc) }
-
scope :by_thread, ->(thread_id) { where(thread_id: thread_id) }
-
scope :needs_review, -> { pending.unmatched }
-
scope :from_account, ->(account) { where(connected_account: account) }
-
scope :for_application, ->(app) { where(interview_application: app) }
-
scope :recruiter_outreach, -> { where(email_type: "recruiter_outreach") }
-
-
# Relevance scopes for smart filtering
-
scope :interview_related, -> {
-
where(email_type: INTERVIEW_TYPES).or(matched)
-
}
-
scope :potential_opportunities, -> { where(email_type: OPPORTUNITY_TYPES) }
-
scope :relevant, -> {
-
visible.where(
-
"email_type IN (?) OR email_type IN (?) OR interview_application_id IS NOT NULL",
-
INTERVIEW_TYPES,
-
OPPORTUNITY_TYPES
-
)
-
}
-
scope :not_ignored, -> { where.not(status: :ignored) }
-
scope :not_auto_ignored, -> { where.not(status: :auto_ignored) }
-
scope :visible, -> { where.not(status: [ :ignored, :auto_ignored ]) }
-
-
# Callbacks
-
before_save :link_or_create_sender
-
-
# Store accessors for metadata
-
store_accessor :metadata, :to_email, :cc_emails, :reply_to, :importance
-
-
# Store accessors for extracted signal intelligence
-
# Company information
-
store_accessor :extracted_data,
-
:signal_company_name, :signal_company_website, :signal_company_careers_url, :signal_company_domain,
-
# Recruiter information
-
:signal_recruiter_name, :signal_recruiter_email, :signal_recruiter_title, :signal_recruiter_linkedin,
-
# Job information
-
:signal_job_title, :signal_job_department, :signal_job_location, :signal_job_url, :signal_job_salary_hint,
-
# Action links (LLM-classified URLs with dynamic labels) and backend actions
-
:signal_action_links, :signal_suggested_actions
-
-
# Extraction scopes
-
scope :extraction_pending, -> { where(extraction_status: "pending") }
-
scope :extraction_completed, -> { where(extraction_status: "completed") }
-
scope :extraction_failed, -> { where(extraction_status: "failed") }
-
scope :needs_extraction, -> { where(extraction_status: [ "pending", "failed" ]) }
-
scope :has_signals, -> { where.not(extracted_data: {}) }
-
-
# Creates a SyncedEmail from a parsed Gmail message
-
#
-
# @param user [User] The user who owns this email
-
# @param connected_account [ConnectedAccount] The Gmail account
-
# @param message_data [Hash] Parsed email data from Gmail::SyncService
-
# @return [SyncedEmail, nil]
-
def self.create_from_gmail_message(user, connected_account, message_data)
-
return nil if message_data.blank? || message_data[:id].blank?
-
-
# Check if already synced
-
existing = find_by(user: user, gmail_id: message_data[:id])
-
return existing if existing
-
-
create!(
-
user: user,
-
connected_account: connected_account,
-
gmail_id: message_data[:id],
-
thread_id: message_data[:thread_id],
-
subject: message_data[:subject],
-
from_email: extract_email(message_data[:from]),
-
from_name: extract_name(message_data[:from]),
-
email_date: message_data[:date],
-
snippet: message_data[:snippet],
-
body_preview: message_data[:body_preview],
-
body_html: message_data[:body_html],
-
labels: message_data[:labels] || [],
-
status: :pending
-
)
-
rescue ActiveRecord::RecordNotUnique
-
# Handle race condition - email already exists
-
find_by(user: user, gmail_id: message_data[:id])
-
rescue ActiveRecord::RecordInvalid => e
-
Rails.logger.warn "Failed to create SyncedEmail: #{e.message}"
-
nil
-
end
-
-
# Extracts email address from "Name <email>" format
-
#
-
# @param from_string [String] The from header value
-
# @return [String]
-
def self.extract_email(from_string)
-
return "" if from_string.blank?
-
-
match = from_string.match(/<([^>]+)>/)
-
match ? match[1].strip.downcase : from_string.strip.downcase
-
end
-
-
# Extracts display name from "Name <email>" format
-
#
-
# @param from_string [String] The from header value
-
# @return [String, nil]
-
def self.extract_name(from_string)
-
return nil if from_string.blank?
-
-
match = from_string.match(/^([^<]+)</)
-
match ? match[1].strip.gsub(/"/, "") : nil
-
end
-
-
# Marks this email as matched to an application
-
#
-
# @param application [InterviewApplication] The matched application
-
# @return [Boolean]
-
def match_to_application!(application)
-
update!(
-
interview_application: application,
-
status: :processed
-
)
-
end
-
-
# Marks this email as ignored (not interview-related)
-
#
-
# @return [Boolean]
-
def ignore!
-
update!(status: :ignored)
-
end
-
-
# Marks this email as processed (manual override).
-
#
-
# @return [Boolean]
-
def mark_processed!
-
update!(status: :processed)
-
end
-
-
# Marks this email as needing review (manual override).
-
#
-
# The "needs review" concept is derived (pending + unmatched), not a persisted status.
-
# This action resets the email back to pending and clears any application match.
-
#
-
# @return [Boolean]
-
def mark_needs_review!
-
update!(status: :pending, interview_application: nil)
-
end
-
-
# Marks processing as failed
-
#
-
# @param reason [String] The failure reason
-
# @return [Boolean]
-
def mark_failed!(reason = nil)
-
update!(
-
status: :failed,
-
metadata: metadata.merge("failure_reason" => reason)
-
)
-
end
-
-
# Checks if this email is matched to an application
-
#
-
# @return [Boolean]
-
def matched?
-
interview_application_id.present?
-
end
-
-
# Returns a short display subject
-
#
-
# @param length [Integer] Maximum length
-
# @return [String]
-
def short_subject(length = 50)
-
subject&.truncate(length) || "(No subject)"
-
end
-
-
# Returns the sender display (name or email)
-
#
-
# @return [String]
-
def sender_display
-
from_name.presence || from_email
-
end
-
-
# Returns the company associated with this email (via sender or application)
-
#
-
# @return [Company, nil]
-
def company
-
interview_application&.company || email_sender&.effective_company
-
end
-
-
# Returns CSS classes for email type badge
-
#
-
# @return [String]
-
def type_badge_classes
-
case email_type
-
when "interview_invite", "scheduling"
-
"bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
-
when "offer"
-
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"
-
when "rejection"
-
"bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300"
-
when "application_confirmation"
-
"bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300"
-
when "assessment"
-
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300"
-
when "recruiter_outreach"
-
"bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300"
-
else
-
"bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
-
end
-
end
-
-
# Returns icon name for email type
-
#
-
# @return [String]
-
def type_icon
-
case email_type
-
when "interview_invite", "scheduling"
-
"calendar"
-
when "offer"
-
"gift"
-
when "rejection"
-
"x-circle"
-
when "application_confirmation"
-
"check-circle"
-
when "assessment"
-
"clipboard-check"
-
when "recruiter_outreach"
-
"sparkles"
-
when "follow_up", "thank_you"
-
"mail"
-
else
-
"mail"
-
end
-
end
-
-
# Checks if this email is a recruiter outreach
-
#
-
# @return [Boolean]
-
def recruiter_outreach?
-
email_type == "recruiter_outreach"
-
end
-
-
# Signal Extraction Methods
-
# -------------------------
-
-
# Checks if signal extraction has been completed
-
#
-
# @return [Boolean]
-
def extraction_completed?
-
extraction_status == "completed"
-
end
-
-
# Checks if this email has extracted signals
-
#
-
# @return [Boolean]
-
def has_signals?
-
extracted_data.present? && extracted_data.keys.any?
-
end
-
-
# Checks if this email has company information extracted
-
#
-
# @return [Boolean]
-
def has_company_signal?
-
signal_company_name.present?
-
end
-
-
# Checks if this email has recruiter information extracted
-
#
-
# @return [Boolean]
-
def has_recruiter_signal?
-
signal_recruiter_name.present? || signal_recruiter_email.present?
-
end
-
-
# Checks if this email has job information extracted
-
#
-
# @return [Boolean]
-
def has_job_signal?
-
signal_job_title.present? || signal_job_url.present?
-
end
-
-
# Checks if this email has action links extracted
-
#
-
# @return [Boolean]
-
def has_action_links?
-
signal_action_links.present? && signal_action_links.any?
-
end
-
-
# Returns the list of backend actions for this email
-
#
-
# @return [Array<String>]
-
def suggested_actions
-
signal_suggested_actions || []
-
end
-
-
# Returns action links sorted by priority (1=highest)
-
# Each link has: url, action_label, priority
-
#
-
# @return [Array<Hash>]
-
def action_links
-
links = signal_action_links || []
-
links.sort_by { |link| link["priority"] || 5 }
-
end
-
-
# Returns the highest priority action link (usually scheduling or apply)
-
#
-
# @return [Hash, nil]
-
def primary_action_link
-
action_links.first
-
end
-
-
# Checks if there's a scheduling-related action link
-
#
-
# @return [Boolean]
-
def has_scheduling_link?
-
action_links.any? do |link|
-
label = link["action_label"].to_s.downcase
-
label.include?("schedule") || label.include?("book") || label.include?("calendar")
-
end
-
end
-
-
# Returns scheduling-related action links
-
#
-
# @return [Array<Hash>]
-
def scheduling_links
-
action_links.select do |link|
-
label = link["action_label"].to_s.downcase
-
label.include?("schedule") || label.include?("book") || label.include?("calendar")
-
end
-
end
-
-
# Marks extraction as started
-
#
-
# @return [Boolean]
-
def mark_extraction_processing!
-
update!(extraction_status: "processing")
-
end
-
-
# Updates with extraction results
-
#
-
# @param data [Hash] Extracted data
-
# @param confidence [Float] Confidence score (0.0-1.0)
-
# @return [Boolean]
-
def update_extraction!(data, confidence: nil)
-
update!(
-
extracted_data: data,
-
extraction_status: "completed",
-
extraction_confidence: confidence,
-
extracted_at: Time.current
-
)
-
end
-
-
# Marks extraction as failed
-
#
-
# @param reason [String] Failure reason
-
# @return [Boolean]
-
def mark_extraction_failed!(reason = nil)
-
update!(
-
extraction_status: "failed",
-
extracted_data: extracted_data.merge("extraction_error" => reason)
-
)
-
end
-
-
# Marks extraction as skipped (not worth extracting)
-
#
-
# @return [Boolean]
-
def mark_extraction_skipped!
-
update!(extraction_status: "skipped")
-
end
-
-
# Returns all emails in this conversation thread
-
# Includes this email, ordered chronologically (oldest first)
-
#
-
# @return [ActiveRecord::Relation<SyncedEmail>]
-
def thread_emails
-
return SyncedEmail.where(id: id) if thread_id.blank?
-
-
SyncedEmail.where(user: user, thread_id: thread_id).chronological
-
end
-
-
# Returns count of emails in this thread
-
#
-
# @return [Integer]
-
def thread_count
-
return 1 if thread_id.blank?
-
-
SyncedEmail.where(user: user, thread_id: thread_id).count
-
end
-
-
# Checks if this email is part of a multi-email thread
-
#
-
# @return [Boolean]
-
def has_thread?
-
thread_count > 1
-
end
-
-
# Returns the first email in this thread (conversation starter)
-
#
-
# @return [SyncedEmail]
-
def thread_root
-
return self if thread_id.blank?
-
-
SyncedEmail.where(user: user, thread_id: thread_id).chronological.first || self
-
end
-
-
# Returns the most recent email in this thread
-
#
-
# @return [SyncedEmail]
-
def thread_latest
-
return self if thread_id.blank?
-
-
SyncedEmail.where(user: user, thread_id: thread_id).recent.first || self
-
end
-
-
# Returns a clean subject without Re:/Fwd: prefixes
-
#
-
# @return [String]
-
def clean_subject
-
return "(No subject)" if subject.blank?
-
-
subject.gsub(/^(re:|fwd?:)\s*/i, "").strip.presence || "(No subject)"
-
end
-
-
# Checks if this email has HTML content
-
#
-
# @return [Boolean]
-
def has_html_body?
-
body_html.present?
-
end
-
-
# Returns the best available body content for display
-
# Prefers plain text for simple display, but HTML is available for rich rendering
-
#
-
# @return [String]
-
def display_body
-
body_preview.presence || snippet.presence || ""
-
end
-
-
# Returns sanitized HTML body safe for rendering
-
# Removes potentially dangerous tags/attributes while preserving formatting
-
#
-
# Security measures:
-
# - Allowlist of safe HTML tags only
-
# - Style attributes sanitized to only allow safe CSS properties
-
# - URL scheme validation for href/src (blocks javascript:, data:, etc.)
-
#
-
# @return [String, nil]
-
def safe_html_body
-
return nil unless body_html.present?
-
-
# First pass: Remove unwanted elements and extract/sanitize styles
-
# We extract styles because Rails sanitizer strips url() values
-
cleaned_html, style_map = strip_unwanted_html_with_styles(body_html)
-
-
# Second pass: Rails sanitizer with safe list of tags
-
# Exclude style attribute - Rails strips url() values
-
# Include data-se-style-id for style re-injection
-
# width/height preserved for proper image sizing
-
sanitized = ActionController::Base.helpers.sanitize(
-
cleaned_html,
-
tags: %w[p br div span a ul ol li strong b em i u h1 h2 h3 h4 h5 h6 blockquote pre code table tr td th thead tbody hr img],
-
attributes: %w[href src alt title class target width height align valign data-se-style-id]
-
)
-
-
# Third pass: Re-inject sanitized styles that were preserved
-
sanitized = reinject_styles(sanitized, style_map)
-
-
# Fourth pass: Validate URL schemes in href and src attributes
-
# Only allow http, https, and mailto schemes
-
sanitize_url_schemes(sanitized)
-
end
-
-
private
-
-
# Removes unwanted HTML elements and extracts sanitized styles
-
# Returns both cleaned HTML and a map of element IDs to their safe styles
-
#
-
# Rails sanitizer strips url() from styles, so we extract styles first,
-
# let Rails sanitize the rest, then re-inject the safe styles after.
-
#
-
# @param html [String] Raw HTML string
-
# @return [Array<String, Hash>] [cleaned_html, style_map]
-
def strip_unwanted_html_with_styles(html)
-
fragment = Nokogiri::HTML::DocumentFragment.parse(html)
-
style_map = {}
-
style_counter = 0
-
-
# Remove non-content elements entirely
-
fragment.css("style, script, noscript, head, title, meta, link").remove
-
-
# Remove elements hidden via inline styles, extract safe styles from others
-
fragment.css("*[style]").each do |node|
-
style = node["style"].to_s.downcase
-
if style.include?("display:none") || style.include?("display: none") ||
-
style.include?("visibility:hidden") || style.include?("visibility: hidden") ||
-
style.include?("font-size:0") || style.include?("font-size: 0") ||
-
style.include?("line-height:0") || style.include?("line-height: 0") ||
-
style.include?("max-height:0") || style.include?("max-height: 0")
-
node.remove
-
next
-
end
-
-
# Sanitize style attribute to only allow safe CSS properties
-
safe_style = extract_safe_styles(node["style"])
-
if safe_style.present?
-
# Store the safe style with a unique marker
-
style_id = "se-style-#{style_counter += 1}"
-
style_map[style_id] = safe_style
-
# Add a data attribute that Rails won't strip
-
node["data-se-style-id"] = style_id
-
end
-
# Remove the style attribute so Rails doesn't mangle it
-
node.remove_attribute("style")
-
end
-
-
# Remove tracking pixels and tiny images (1x1, 0x0, etc.)
-
fragment.css("img").each do |img|
-
width = img["width"].to_s.gsub(/\D/, "").to_i
-
height = img["height"].to_s.gsub(/\D/, "").to_i
-
# Remove if explicitly tiny (tracking pixels)
-
if (width > 0 && width <= 3) || (height > 0 && height <= 3)
-
img.remove
-
end
-
end
-
-
# Remove HTML comments
-
fragment.traverse do |node|
-
node.remove if node.comment?
-
end
-
-
# Remove empty elements that create whitespace (multiple passes)
-
2.times do
-
fragment.css("div, span, p, td, tr, table").each do |node|
-
# Check if element has no meaningful content
-
text_content = node.text.to_s.strip
-
has_images = node.css("img").any?
-
has_links = node.css("a").any? { |a| a.text.to_s.strip.present? }
-
-
# Remove if empty (no text, no images, no meaningful links)
-
if text_content.empty? && !has_images && !has_links
-
# Keep if it has child elements with content
-
has_content_children = node.children.any? do |child|
-
child.element? && (child.text.to_s.strip.present? || child.css("img").any?)
-
end
-
node.remove unless has_content_children
-
end
-
end
-
end
-
-
# Remove leading br tags
-
while (first = fragment.children.first) && first.name == "br"
-
first.remove
-
end
-
-
# Detect and mark signature sections (images after text content ends)
-
mark_signature_images(fragment)
-
-
[ fragment.to_html, style_map ]
-
rescue StandardError
-
[ html, {} ]
-
end
-
-
# Marks images that appear to be in email signatures
-
# Signatures typically appear after the main text content
-
#
-
# @param fragment [Nokogiri::HTML::DocumentFragment]
-
def mark_signature_images(fragment)
-
all_images = fragment.css("img").to_a
-
return if all_images.empty?
-
-
# Find signature section by looking for common patterns
-
signature_indicators = [
-
"Best regards", "Kind regards", "Regards", "Thanks", "Thank you",
-
"Cheers", "Best,", "Sincerely", "Warmly", "Yours",
-
"Get Outlook", "Sent from", "—", "--"
-
]
-
-
# Count images and their positions
-
total_text_length = fragment.text.to_s.length
-
-
all_images.each_with_index do |img, idx|
-
src = img["src"].to_s.downcase
-
alt = img["alt"].to_s.downcase
-
-
# Calculate approximate position of this image in the document
-
text_before_img = ""
-
img.traverse { |n| break if n == img; text_before_img += n.text.to_s if n.text? }
-
position_ratio = total_text_length > 0 ? text_before_img.length.to_f / total_text_length : 1.0
-
-
# Check if it's likely a social media icon by URL pattern
-
is_social_url = src.match?(/linkedin|facebook|twitter|instagram|youtube|social|fbcdn|licdn|static\.licdn/)
-
-
# Check if it's likely a social media icon by alt text
-
is_social_alt = alt.match?(/linkedin|facebook|twitter|instagram|youtube|follow|connect/)
-
-
# Check if image appears in latter half of email (signature area)
-
is_in_signature_area = position_ratio > 0.5
-
-
# Check if multiple images appear consecutively (common for social icon rows)
-
has_sibling_images = idx > 0 || (idx < all_images.length - 1)
-
-
# Mark as social icon if matches patterns
-
if is_social_url || is_social_alt || (is_in_signature_area && has_sibling_images && idx > 0)
-
existing_class = img["class"].to_s
-
img["class"] = "#{existing_class} email-social-icon".strip
-
end
-
end
-
end
-
-
# Validates and sanitizes URL schemes in href and src attributes
-
# Blocks dangerous schemes like javascript:, data:, vbscript:, etc.
-
#
-
# @param html [String] The HTML to sanitize
-
# @return [String] HTML with dangerous URLs removed
-
def sanitize_url_schemes(html)
-
return html if html.blank?
-
-
safe_schemes = %w[http https mailto]
-
-
# Parse and sanitize href attributes
-
html = html.gsub(/\bhref\s*=\s*["']([^"']*)["']/i) do |match|
-
url = Regexp.last_match(1).to_s.strip.downcase
-
scheme = url.split(":").first
-
-
if url.start_with?("/", "#") || safe_schemes.include?(scheme)
-
match
-
else
-
'href="#"'
-
end
-
end
-
-
# Parse and sanitize src attributes
-
html.gsub(/\bsrc\s*=\s*["']([^"']*)["']/i) do |match|
-
url = Regexp.last_match(1).to_s.strip.downcase
-
scheme = url.split(":").first
-
-
if url.start_with?("/") || %w[http https].include?(scheme)
-
match
-
else
-
'src=""'
-
end
-
end
-
end
-
-
# Re-injects sanitized styles that were extracted before Rails sanitization
-
# Replaces data-se-style-id attributes with the corresponding style attributes
-
#
-
# @param html [String] The HTML with data-se-style-id markers
-
# @param style_map [Hash] Map of style IDs to sanitized CSS strings
-
# @return [String] HTML with styles re-injected
-
def reinject_styles(html, style_map)
-
return html if html.blank? || style_map.empty?
-
-
fragment = Nokogiri::HTML::DocumentFragment.parse(html)
-
-
fragment.css("*[data-se-style-id]").each do |node|
-
style_id = node["data-se-style-id"]
-
safe_style = style_map[style_id]
-
-
if safe_style.present?
-
node["style"] = safe_style
-
end
-
-
# Always remove the marker attribute
-
node.remove_attribute("data-se-style-id")
-
end
-
-
fragment.to_html
-
rescue StandardError
-
html
-
end
-
-
# Sanitizes inline style attributes to only allow safe CSS properties
-
# Removes dangerous CSS like expressions, url() with javascript, etc.
-
#
-
# @param html [String] The HTML with style attributes
-
# @return [String] HTML with sanitized style attributes
-
def sanitize_inline_styles(html)
-
return html if html.blank?
-
-
fragment = Nokogiri::HTML::DocumentFragment.parse(html)
-
-
fragment.css("*[style]").each do |node|
-
original_style = node["style"].to_s
-
safe_style = extract_safe_styles(original_style)
-
-
if safe_style.present?
-
node["style"] = safe_style
-
else
-
node.remove_attribute("style")
-
end
-
end
-
-
fragment.to_html
-
rescue StandardError
-
html
-
end
-
-
# Extracts only safe CSS properties from a style string
-
# Filters out dangerous values like url(), expression(), javascript:
-
#
-
# @param style_string [String] The raw CSS style string
-
# @return [String] Filtered CSS declarations
-
def extract_safe_styles(style_string)
-
return "" if style_string.blank?
-
-
safe_declarations = style_string.split(";").filter_map do |declaration|
-
property, value = declaration.split(":", 2).map(&:strip)
-
next unless property.present? && value.present?
-
-
# Normalize property name for comparison
-
property_lower = property.downcase.gsub(/\s+/, "-")
-
-
# Only keep safe properties
-
next unless SAFE_STYLE_PROPERTIES.include?(property_lower)
-
-
# Reject dangerous values
-
value_lower = value.downcase
-
next if value_lower.include?("url(") && !safe_url_in_style?(value)
-
next if value_lower.include?("expression(")
-
next if value_lower.include?("javascript:")
-
next if value_lower.include?("vbscript:")
-
next if value_lower.include?("behavior:")
-
next if value_lower.include?("-moz-binding")
-
-
"#{property}: #{value}"
-
end
-
-
safe_declarations.join("; ")
-
end
-
-
# Checks if a url() in CSS is safe (http/https only)
-
#
-
# @param value [String] The CSS value containing url()
-
# @return [Boolean]
-
def safe_url_in_style?(value)
-
# Extract URL from url(...) or url("...") or url('...')
-
urls = value.scan(/url\s*\(\s*['"]?([^'")]+)['"]?\s*\)/i).flatten
-
urls.all? do |url|
-
url.strip.downcase.start_with?("http://", "https://", "/")
-
end
-
end
-
-
# Links or creates the email sender record
-
#
-
# @return [void]
-
def link_or_create_sender
-
return if email_sender_id.present? || from_email.blank?
-
-
self.email_sender = EmailSender.find_or_create_from_email(from_email, from_name)
-
end
-
end
-
class Transition < ApplicationRecord
-
belongs_to :resource, polymorphic: true
-
end
-
# frozen_string_literal: true
-
-
# User model representing registered users
-
1
class User < ApplicationRecord
-
1
extend FriendlyId
-
1
friendly_id :slug_candidates, use: [ :slugged, :finders ]
-
-
1
has_secure_password
-
1
has_many :sessions, dependent: :destroy
-
1
has_many :interview_applications, dependent: :destroy
-
1
has_many :interview_rounds, through: :interview_applications
-
1
has_one :preference, class_name: "UserPreference", dependent: :destroy
-
1
has_many :connected_accounts, dependent: :destroy
-
1
has_many :synced_emails, dependent: :destroy
-
1
has_many :opportunities, dependent: :destroy
-
1
has_many :saved_jobs, dependent: :destroy
-
1
has_many :fit_assessments, dependent: :destroy
-
1
has_many :interview_prep_artifacts, dependent: :destroy
-
-
# =================================================================
-
# Billing & subscriptions
-
# =================================================================
-
1
has_many :billing_customers, class_name: "Billing::Customer", dependent: :destroy
-
1
has_many :billing_subscriptions, class_name: "Billing::Subscription", dependent: :destroy
-
1
has_many :billing_orders, class_name: "Billing::Order", dependent: :destroy
-
1
has_many :billing_entitlement_grants, class_name: "Billing::EntitlementGrant", dependent: :destroy
-
1
has_many :billing_usage_counters, class_name: "Billing::UsageCounter", dependent: :destroy
-
-
# Resume and skill profile associations
-
1
has_many :user_resumes, dependent: :destroy
-
1
has_many :user_skills, dependent: :destroy
-
1
has_many :skill_tags, through: :user_skills
-
-
# Current job role and company associations
-
1
belongs_to :current_job_role, class_name: "JobRole", optional: true
-
1
belongs_to :current_company, class_name: "Company", optional: true
-
-
# Target job roles, companies, and domains
-
1
has_many :user_target_job_roles, dependent: :destroy
-
1
has_many :target_job_roles, through: :user_target_job_roles, source: :job_role
-
1
has_many :user_target_companies, dependent: :destroy
-
1
has_many :target_companies, through: :user_target_companies, source: :company
-
1
has_many :user_target_domains, dependent: :destroy
-
1
has_many :target_domains, through: :user_target_domains, source: :domain
-
-
# Work experience (aggregated from resumes + manual entries)
-
1
has_many :user_work_experiences, dependent: :destroy
-
-
# Virtual attribute for terms acceptance checkbox
-
1
attribute :terms_accepted, :boolean, default: false
-
-
1
normalizes :email_address, with: ->(e) { e.strip.downcase }
-
-
1
validates :email_address, presence: true, uniqueness: true
-
1
validates :terms_accepted, acceptance: { accept: true, message: "You must accept the Terms of Service and Privacy Policy" }, on: :create
-
-
# Set terms_accepted_at when terms are accepted
-
1
before_create :set_terms_accepted_at, if: :terms_accepted
-
-
# Generate token for email verification
-
1
generates_token_for :email_verification, expires_in: 24.hours
-
-
1
after_create :create_default_preference
-
-
1
before_create :generate_uuid
-
-
# Returns the user's preference or builds a default one
-
# @return [UserPreference]
-
1
def preference
-
super || build_preference
-
end
-
-
# Returns the slug candidates for the user
-
# @return [Array<String>]
-
1
def slug_candidates
-
[
-
:name,
-
[ :name, :uuid ]
-
]
-
end
-
-
# Returns the total number of applications for this user
-
# @return [Integer] Total application count
-
1
def total_applications_count
-
interview_applications.count
-
end
-
-
# Returns applications grouped by status
-
# @return [Hash] Applications grouped by status
-
1
def applications_by_status
-
interview_applications.group_by(&:status)
-
end
-
-
# Returns the user's display name or email
-
# @return [String]
-
1
def display_name
-
name.presence || email_address.split("@").first
-
end
-
-
# Returns current role display name
-
# @return [String, nil]
-
1
def current_role_name
-
then: 0
else: 0
current_job_role&.title
-
end
-
-
# Returns current company display name
-
# @return [String, nil]
-
1
def current_company_name
-
then: 0
else: 0
current_company&.name
-
end
-
-
# Checks if the user is an admin
-
# @return [Boolean] True if user has admin privileges
-
1
def admin?
-
is_admin == true
-
end
-
-
# Returns the Google connected account if any
-
# @return [ConnectedAccount, nil]
-
1
def google_account
-
connected_accounts.google.first
-
end
-
-
# Checks if user has Google connected
-
# @return [Boolean]
-
1
def google_connected?
-
connected_accounts.google.exists?
-
end
-
-
# Checks if the user's email has been verified
-
# @return [Boolean]
-
1
def email_verified?
-
email_verified_at.present?
-
end
-
-
# Marks the user's email as verified
-
# @return [Boolean]
-
1
def verify_email!
-
update(email_verified_at: Time.current)
-
end
-
-
# Checks if user signed up via OAuth
-
# @return [Boolean]
-
1
def oauth_user?
-
oauth_provider.present?
-
end
-
-
# Internal billing override for staff/admins (all features enabled).
-
#
-
# @return [Boolean]
-
1
def billing_admin_access?
-
Billing::AdminAccessService.new(user: self).active?
-
end
-
-
# Returns the user's aggregated skill profile
-
# @return [ActiveRecord::Relation<UserSkill>]
-
1
def skill_profile
-
user_skills.includes(:skill_tag).by_level_desc
-
end
-
-
# Returns the user's top skills
-
# @param limit [Integer] Number of skills to return
-
# @return [ActiveRecord::Relation<UserSkill>]
-
1
def top_skills(limit: 10)
-
UserSkill.top_skills(self, limit: limit)
-
end
-
-
# Checks if user has uploaded any resumes
-
# @return [Boolean]
-
1
def has_resumes?
-
user_resumes.exists?
-
end
-
-
# Returns the count of analyzed resumes
-
# @return [Integer]
-
1
def analyzed_resumes_count
-
user_resumes.analyzed.count
-
end
-
-
1
private
-
-
1
def create_default_preference
-
else: 0
then: 0
create_preference! unless preference.persisted?
-
end
-
-
1
def set_terms_accepted_at
-
self.terms_accepted_at = Time.current
-
end
-
-
# Generates a UUID for the user
-
# @return [String]
-
1
def generate_uuid
-
self.uuid = SecureRandom.uuid
-
end
-
end
-
# frozen_string_literal: true
-
-
# UserPreference model for managing user settings and preferences
-
class UserPreference < ApplicationRecord
-
VIEWS = [ "kanban", "table" ].freeze
-
THEMES = [ "light", "dark", "system" ].freeze
-
AI_INSIGHTS_FREQUENCIES = [ "daily", "weekly", "on_demand" ].freeze
-
-
belongs_to :user
-
-
# Validations
-
validates :user, presence: true, uniqueness: true
-
validates :preferred_view, inclusion: { in: VIEWS + [ "list" ] } # Allow "list" for backward compatibility
-
validates :theme, inclusion: { in: THEMES }
-
validates :ai_insights_frequency, inclusion: { in: AI_INSIGHTS_FREQUENCIES }, allow_nil: true
-
validates :data_retention_days, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true
-
-
# Normalize view preference (convert "list" to "table")
-
before_validation :normalize_view_preference
-
-
# Returns true if user prefers kanban view
-
# @return [Boolean]
-
def kanban_view?
-
preferred_view == "kanban"
-
end
-
-
# Returns true if user prefers table view
-
# @return [Boolean]
-
def table_view?
-
preferred_view == "table" || preferred_view == "list"
-
end
-
-
# Returns true if user prefers list view (deprecated, use table_view?)
-
# @return [Boolean]
-
def list_view?
-
table_view?
-
end
-
-
# Returns true if AI feedback analysis is enabled
-
# @return [Boolean]
-
def ai_feedback_analysis?
-
ai_feedback_analysis != false
-
end
-
-
# Returns true if AI interview prep is enabled
-
# @return [Boolean]
-
def ai_interview_prep?
-
ai_interview_prep != false
-
end
-
-
# Returns true if weekly digest emails are enabled
-
# @return [Boolean]
-
def email_weekly_digest?
-
email_weekly_digest != false
-
end
-
-
# Returns true if interview reminder emails are enabled
-
# @return [Boolean]
-
def email_interview_reminders?
-
email_interview_reminders != false
-
end
-
-
# Returns the effective AI insights frequency, defaulting to weekly
-
# @return [String]
-
def effective_ai_insights_frequency
-
ai_insights_frequency || "weekly"
-
end
-
-
# Returns true if data should be retained indefinitely
-
# @return [Boolean]
-
def retain_data_forever?
-
data_retention_days.nil? || data_retention_days == 0
-
end
-
-
private
-
-
# Normalize "list" to "table" for consistency
-
def normalize_view_preference
-
self.preferred_view = "table" if preferred_view == "list"
-
end
-
-
# Returns true if dark mode is enabled
-
# @return [Boolean]
-
def dark_mode?
-
theme == "dark"
-
end
-
end
-
# frozen_string_literal: true
-
-
# UserResume model representing uploaded CVs/resumes for skill extraction
-
#
-
# @example
-
# resume = user.user_resumes.create!(name: "Backend - Generic", file: uploaded_file)
-
# AnalyzeResumeJob.perform_later(resume)
-
#
-
class UserResume < ApplicationRecord
-
extend FriendlyId
-
friendly_id :slug_candidates, use: [ :slugged, :finders ]
-
-
# Constants
-
ALLOWED_CONTENT_TYPES = %w[
-
application/pdf
-
application/msword
-
application/vnd.openxmlformats-officedocument.wordprocessingml.document
-
text/plain
-
].freeze
-
-
MAX_FILE_SIZE = 10.megabytes
-
-
# Enums
-
enum :purpose, {
-
generic: 0,
-
company_specific: 1,
-
role_specific: 2
-
}, prefix: true
-
-
enum :analysis_status, {
-
pending: 0,
-
processing: 1,
-
completed: 2,
-
failed: 3
-
}, prefix: true
-
-
# Associations
-
belongs_to :user
-
has_many :resume_skills, dependent: :destroy
-
has_many :skill_tags, through: :resume_skills
-
has_many :resume_work_experiences, dependent: :destroy
-
-
# Target roles and companies (many-to-many)
-
has_many :user_resume_target_job_roles, dependent: :destroy
-
has_many :target_job_roles, through: :user_resume_target_job_roles, source: :job_role
-
has_many :user_resume_target_companies, dependent: :destroy
-
has_many :target_companies, through: :user_resume_target_companies, source: :company
-
-
# ActiveStorage
-
has_one_attached :file
-
-
# Validations
-
validates :name, presence: true
-
validates :file, presence: true, on: :create
-
-
validate :acceptable_file, if: -> { file.attached? }
-
-
# Scopes
-
scope :by_user, ->(user) { where(user: user) }
-
scope :analyzed, -> { where(analysis_status: :completed) }
-
scope :pending_analysis, -> { where(analysis_status: :pending) }
-
scope :recent_first, -> { order(created_at: :desc) }
-
scope :by_purpose, ->(purpose) { where(purpose: purpose) }
-
-
# Callbacks
-
after_create_commit :enqueue_analysis
-
-
def slug_candidates
-
[
-
:name,
-
[ :name, :purpose ],
-
[ :name, :purpose, :user_uuid ]
-
]
-
end
-
-
def user_uuid
-
user.uuid
-
end
-
-
# Returns the file extension
-
#
-
# @return [String, nil] File extension (e.g., "pdf", "docx")
-
def file_extension
-
return nil unless file.attached?
-
-
file.filename.extension.downcase
-
end
-
-
# Returns a human-readable file type
-
#
-
# @return [String] File type description
-
def file_type
-
case file_extension
-
when "pdf" then "PDF"
-
when "doc" then "Word (DOC)"
-
when "docx" then "Word (DOCX)"
-
when "txt" then "Plain Text"
-
else "Unknown"
-
end
-
end
-
-
# Checks if analysis is complete
-
#
-
# @return [Boolean]
-
def analyzed?
-
analysis_status_completed?
-
end
-
-
# Checks if analysis is in progress
-
#
-
# @return [Boolean]
-
def analyzing?
-
analysis_status_processing?
-
end
-
-
# Checks if this resume has any target roles
-
#
-
# @return [Boolean]
-
def has_target_roles?
-
target_job_roles.exists?
-
end
-
-
# Checks if this resume has any target companies
-
#
-
# @return [Boolean]
-
def has_target_companies?
-
target_companies.exists?
-
end
-
-
# Returns a summary of targets for display
-
#
-
# @return [String, nil]
-
def targets_summary
-
parts = []
-
parts << target_job_roles.pluck(:title).join(", ") if has_target_roles?
-
parts << "@ #{target_companies.pluck(:name).join(", ")}" if has_target_companies?
-
parts.any? ? parts.join(" ") : nil
-
end
-
-
# Returns the effective proficiency level for a skill
-
# Prefers user_level over model_level
-
#
-
# @param skill_tag [SkillTag] The skill to check
-
# @return [Integer, nil] Proficiency level 1-5
-
def proficiency_for(skill_tag)
-
resume_skill = resume_skills.find_by(skill_tag: skill_tag)
-
return nil unless resume_skill
-
-
resume_skill.user_level || resume_skill.model_level
-
end
-
-
# Marks analysis as started
-
#
-
# @return [Boolean]
-
def start_analysis!
-
update!(analysis_status: :processing)
-
end
-
-
# Marks analysis as completed
-
#
-
# @param summary [String, nil] AI-generated summary
-
# @return [Boolean]
-
def complete_analysis!(summary: nil)
-
update!(
-
analysis_status: :completed,
-
analyzed_at: Time.current,
-
analysis_summary: summary
-
)
-
end
-
-
# Marks analysis as failed
-
#
-
# @param error_message [String, nil] Error description
-
# @return [Boolean]
-
def fail_analysis!(error_message: nil)
-
update!(
-
analysis_status: :failed,
-
extracted_data: extracted_data.merge(error: error_message)
-
)
-
end
-
-
private
-
-
# Validates attached file type and size
-
def acceptable_file
-
unless file.blob.byte_size <= MAX_FILE_SIZE
-
errors.add(:file, "is too large (max #{MAX_FILE_SIZE / 1.megabyte}MB)")
-
end
-
-
unless ALLOWED_CONTENT_TYPES.include?(file.blob.content_type)
-
errors.add(:file, "must be a PDF, Word document, or plain text file")
-
end
-
end
-
-
# Enqueues background analysis job
-
def enqueue_analysis
-
AnalyzeResumeJob.perform_later(self)
-
end
-
end
-
# frozen_string_literal: true
-
-
# Join model connecting UserResume to target Companies
-
class UserResumeTargetCompany < ApplicationRecord
-
belongs_to :user_resume
-
belongs_to :company
-
-
validates :user_resume_id, uniqueness: { scope: :company_id }
-
end
-
# frozen_string_literal: true
-
-
# Join model connecting UserResume to target JobRoles
-
class UserResumeTargetJobRole < ApplicationRecord
-
belongs_to :user_resume
-
belongs_to :job_role
-
-
validates :user_resume_id, uniqueness: { scope: :job_role_id }
-
end
-
# frozen_string_literal: true
-
-
# UserSkill model representing aggregated skill profile across all resumes
-
#
-
# Computed from ResumeSkills with weighted averaging based on recency and purpose
-
#
-
# @example
-
# user_skill = user.user_skills.find_by(skill_tag: ruby_skill)
-
# user_skill.aggregated_level # => 4.2
-
# user_skill.resume_count # => 3
-
#
-
class UserSkill < ApplicationRecord
-
# Associations
-
belongs_to :user
-
belongs_to :skill_tag
-
-
# Delegations
-
delegate :name, to: :skill_tag, prefix: :skill
-
-
# Validations
-
validates :aggregated_level, presence: true, numericality: { greater_than_or_equal_to: 1, less_than_or_equal_to: 5 }
-
validates :user_id, uniqueness: { scope: :skill_tag_id, message: "skill already exists for this user" }
-
validates :resume_count, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
-
-
# Scopes
-
scope :by_category, ->(category) { where(category: category) }
-
scope :strong_skills, -> { where("aggregated_level >= ?", 4.0) }
-
scope :moderate_skills, -> { where(aggregated_level: 2.5..3.9) }
-
scope :developing_skills, -> { where("aggregated_level < ?", 2.5) }
-
scope :by_level_desc, -> { order(aggregated_level: :desc) }
-
scope :by_level_asc, -> { order(aggregated_level: :asc) }
-
scope :alphabetical, -> { joins(:skill_tag).order("skill_tags.name ASC") }
-
scope :most_demonstrated, -> { order(resume_count: :desc) }
-
scope :recent, -> { order(last_demonstrated_at: :desc) }
-
-
# Returns proficiency as a rounded integer
-
#
-
# @return [Integer] Rounded proficiency level 1-5
-
def rounded_level
-
aggregated_level.round
-
end
-
-
# Returns a human-readable proficiency label
-
#
-
# @return [String] Proficiency description
-
def proficiency_label
-
case rounded_level
-
when 1 then "Beginner"
-
when 2 then "Elementary"
-
when 3 then "Intermediate"
-
when 4 then "Advanced"
-
when 5 then "Expert"
-
else "Unknown"
-
end
-
end
-
-
# Returns confidence as a percentage
-
#
-
# @return [Integer] Confidence percentage 0-100
-
def confidence_percentage
-
return 0 unless confidence_score
-
-
(confidence_score * 100).round
-
end
-
-
# Checks if this is a strong skill (4+)
-
#
-
# @return [Boolean]
-
def strong?
-
aggregated_level >= 4.0
-
end
-
-
# Checks if this is a developing skill (<2.5)
-
#
-
# @return [Boolean]
-
def developing?
-
aggregated_level < 2.5
-
end
-
-
# Returns the source resumes for this skill
-
#
-
# @return [ActiveRecord::Relation<UserResume>]
-
def source_resumes
-
UserResume.joins(:resume_skills)
-
.where(user: user, resume_skills: { skill_tag: skill_tag })
-
.distinct
-
end
-
-
# Class method to get skills grouped by category
-
#
-
# @param user [User] The user
-
# @return [Hash] Skills grouped by category
-
def self.grouped_by_category(user)
-
where(user: user)
-
.includes(:skill_tag)
-
.order(aggregated_level: :desc)
-
.group_by(&:category)
-
end
-
-
# Class method to get top N skills for a user
-
#
-
# @param user [User] The user
-
# @param limit [Integer] Number of skills to return
-
# @return [ActiveRecord::Relation<UserSkill>]
-
def self.top_skills(user, limit: 10)
-
where(user: user).by_level_desc.limit(limit)
-
end
-
-
# Class method to get skills needing development
-
#
-
# @param user [User] The user
-
# @param limit [Integer] Number of skills to return
-
# @return [ActiveRecord::Relation<UserSkill>]
-
def self.development_areas(user, limit: 5)
-
where(user: user).developing_skills.by_level_asc.limit(limit)
-
end
-
end
-
# frozen_string_literal: true
-
-
# UserTargetCompany join model for user's target companies
-
class UserTargetCompany < ApplicationRecord
-
belongs_to :user
-
belongs_to :company
-
-
validates :user, presence: true
-
validates :company, presence: true
-
validates :company_id, uniqueness: { scope: :user_id }
-
-
scope :ordered, -> { order(priority: :asc, created_at: :asc) }
-
end
-
# frozen_string_literal: true
-
-
# UserTargetDomain join model for user's target domains
-
class UserTargetDomain < ApplicationRecord
-
belongs_to :user
-
belongs_to :domain
-
-
validates :user, presence: true
-
validates :domain, presence: true
-
validates :domain_id, uniqueness: { scope: :user_id }
-
-
scope :ordered, -> { order(priority: :asc, created_at: :asc) }
-
end
-
# frozen_string_literal: true
-
-
# UserTargetJobRole join model for user's target job roles
-
class UserTargetJobRole < ApplicationRecord
-
belongs_to :user
-
belongs_to :job_role
-
-
validates :user, presence: true
-
validates :job_role, presence: true
-
validates :job_role_id, uniqueness: { scope: :user_id }
-
-
scope :ordered, -> { order(priority: :asc, created_at: :asc) }
-
end
-
# frozen_string_literal: true
-
-
# UserWorkExperience is a merged, user-level view of work history aggregated across resumes.
-
# It preserves provenance via UserWorkExperienceSource.
-
# Can also be manually created/edited by users.
-
class UserWorkExperience < ApplicationRecord
-
belongs_to :user
-
belongs_to :company, optional: true
-
belongs_to :job_role, optional: true
-
-
has_many :user_work_experience_sources, dependent: :destroy
-
has_many :resume_work_experiences, through: :user_work_experience_sources
-
-
has_many :user_work_experience_skills, dependent: :destroy
-
has_many :skill_tags, through: :user_work_experience_skills
-
-
# Source type: ai_extracted (from resume analysis) or manual (user-created)
-
enum :source_type, [ :ai_extracted, :manual ], default: :ai_extracted
-
-
validates :role_title, presence: true
-
validates :company_name, presence: true
-
-
scope :reverse_chronological, -> { order(Arel.sql("COALESCE(end_date, start_date) DESC NULLS LAST"), created_at: :desc) }
-
scope :ai_extracted_only, -> { where(source_type: :ai_extracted) }
-
scope :manual_only, -> { where(source_type: :manual) }
-
-
# @return [String]
-
def display_company_name
-
company&.name.presence || company_name.to_s
-
end
-
-
# @return [String]
-
def display_role_title
-
job_role&.title.presence || role_title.to_s
-
end
-
end
-
# frozen_string_literal: true
-
-
# Aggregated join between UserWorkExperience and SkillTag.
-
# Tracks how many source resume experiences mention the skill and when it was last used.
-
class UserWorkExperienceSkill < ApplicationRecord
-
belongs_to :user_work_experience
-
belongs_to :skill_tag
-
-
validates :user_work_experience_id, uniqueness: { scope: :skill_tag_id }
-
validates :source_count, numericality: { greater_than_or_equal_to: 0 }
-
end
-
# frozen_string_literal: true
-
-
# Provenance join from a merged UserWorkExperience back to individual ResumeWorkExperience rows.
-
class UserWorkExperienceSource < ApplicationRecord
-
belongs_to :user_work_experience
-
belongs_to :resume_work_experience
-
-
validates :user_work_experience_id, uniqueness: { scope: :resume_work_experience_id }
-
end
-
# frozen_string_literal: true
-
-
module Ai
-
# Service for recording LLM API calls with full observability
-
#
-
# Wraps LLM calls to capture timing, tokens, costs, and full
-
# request/response payloads for debugging and analytics.
-
#
-
# @example
-
# logger = Ai::ApiLoggerService.new(
-
# operation_type: :job_extraction,
-
# loggable: job_listing,
-
# provider: "anthropic",
-
# model: "claude-sonnet-4-20250514"
-
# )
-
# result = logger.record do |log_context|
-
# # Make AI call here
-
# # Return { content: "...", input_tokens: 100, output_tokens: 50 }
-
# end
-
#
-
class ApiLoggerService
-
attr_reader :log
-
-
# Initialize the logger service
-
#
-
# @param operation_type [String, Symbol] The type of operation (job_extraction, email_extraction, resume_extraction)
-
# @param loggable [ApplicationRecord, nil] The object being processed (JobListing, Opportunity, UserResume)
-
# @param provider [String] The AI provider name
-
# @param model [String] The model identifier
-
# @param llm_prompt [Ai::LlmPrompt, nil] Optional prompt template used
-
def initialize(operation_type:, loggable: nil, provider:, model:, llm_prompt: nil)
-
@operation_type = operation_type.to_s
-
@loggable = loggable
-
@provider = provider
-
@model = model
-
@llm_prompt = llm_prompt
-
@log = nil
-
end
-
-
# Records an LLM API call with full observability
-
#
-
# Captures timing, tokens, and payloads. The block should return a hash with:
-
# - content: The extracted content/response
-
# - input_tokens: Number of input tokens
-
# - output_tokens: Number of output tokens
-
# - confidence: Confidence score (0.0-1.0)
-
# - error: Error message if failed
-
# - error_type: Type of error if failed
-
#
-
# @param prompt [String, nil] The prompt text (for logging)
-
# @param content_size [Integer, nil] Size of content in bytes
-
# @yield [log_context] Block that performs the AI call
-
# @return [Hash] The result from the block, with logging metadata added
-
def record(prompt: nil, content_size: nil)
-
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
-
-
# Create initial log record
-
@log = Ai::LlmApiLog.new(
-
operation_type: @operation_type,
-
loggable: @loggable,
-
llm_prompt: @llm_prompt,
-
provider: @provider,
-
model: @model,
-
content_size: content_size,
-
request_payload: build_request_payload(prompt: prompt),
-
status: :success
-
)
-
-
begin
-
# Execute the AI call
-
result = yield(self)
-
-
# Calculate latency
-
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
-
latency_ms = ((end_time - start_time) * 1000).round
-
-
# Update log with results
-
@log.assign_attributes(
-
latency_ms: latency_ms,
-
input_tokens: result[:input_tokens],
-
output_tokens: result[:output_tokens],
-
confidence_score: result[:confidence],
-
request_payload: build_request_payload(prompt: prompt, result: result),
-
response_payload: build_response_payload(result),
-
extracted_fields: extract_field_names(result),
-
status: determine_status(result)
-
)
-
-
# Handle errors in result
-
if result[:error].present?
-
@log.assign_attributes(
-
error_message: result[:error],
-
error_type: result[:error_type] || classify_error(result[:error])
-
)
-
end
-
-
@log.save!
-
-
# Return result with log reference
-
result.merge(llm_api_log_id: @log.id)
-
-
rescue => e
-
# Calculate latency even for failures
-
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
-
latency_ms = ((end_time - start_time) * 1000).round
-
-
# Record the error
-
@log.assign_attributes(
-
latency_ms: latency_ms,
-
status: classify_exception_status(e),
-
error_type: e.class.name,
-
error_message: e.message,
-
response_payload: build_exception_response_payload(e)
-
)
-
-
@log.save!
-
-
# Notify with rich context for easier debugging (thread/trace/log ids, etc).
-
# This is intentionally best-effort so logging never hides the original exception.
-
begin
-
Ai::ErrorReporter.notify(
-
e,
-
operation: @operation_type,
-
provider: @provider,
-
model: @model,
-
user: @loggable.is_a?(User) ? @loggable : nil,
-
thread: @loggable.is_a?(Assistant::ChatThread) ? @loggable : nil,
-
trace_id: nil,
-
llm_api_log_id: @log.id,
-
extra: { llm_prompt_id: @llm_prompt&.id, loggable_type: @loggable&.class&.name, loggable_id: @loggable&.id }
-
)
-
rescue StandardError
-
# best-effort only
-
end
-
-
# Re-raise the exception
-
raise
-
end
-
end
-
-
# Records a simple result without wrapping
-
#
-
# Use this when you've already made the AI call and just want to log it.
-
#
-
# @param result [Hash] The extraction result
-
# @param latency_ms [Integer] Latency in milliseconds
-
# @param prompt [String, nil] The prompt text
-
# @param content_size [Integer, nil] Size of content
-
# @return [Ai::LlmApiLog] The created log record
-
def record_result(result, latency_ms:, prompt: nil, content_size: nil)
-
@log = Ai::LlmApiLog.create!(
-
operation_type: @operation_type,
-
loggable: @loggable,
-
llm_prompt: @llm_prompt,
-
provider: @provider,
-
model: @model,
-
content_size: content_size,
-
request_payload: build_request_payload(prompt: prompt, result: result),
-
response_payload: build_response_payload(result),
-
latency_ms: latency_ms,
-
input_tokens: result[:input_tokens],
-
output_tokens: result[:output_tokens],
-
confidence_score: result[:confidence],
-
extracted_fields: extract_field_names(result),
-
status: determine_status(result),
-
error_message: result[:error],
-
error_type: result[:error_type] || (result[:error].present? ? classify_error(result[:error]) : nil)
-
)
-
end
-
-
private
-
-
# Determines the status based on the result
-
#
-
# @param result [Hash] The extraction result
-
# @return [Symbol] The status
-
def determine_status(result)
-
return :rate_limited if result[:rate_limit]
-
return :timeout if result[:timeout]
-
return :error if result[:error].present?
-
-
:success
-
end
-
-
# Classifies exception into status
-
#
-
# @param exception [Exception] The exception
-
# @return [Symbol] The status
-
def classify_exception_status(exception)
-
message = exception.message.to_s.downcase
-
-
return :rate_limited if message.include?("rate") && message.include?("limit")
-
return :timeout if message.include?("timeout") || exception.is_a?(Timeout::Error)
-
-
:error
-
end
-
-
# Classifies error message into type
-
#
-
# @param error [String] The error message
-
# @return [String] Error type
-
def classify_error(error)
-
return "rate_limit" if error.to_s.downcase.include?("rate")
-
return "timeout" if error.to_s.downcase.include?("timeout")
-
return "parsing" if error.to_s.downcase.include?("json") || error.to_s.downcase.include?("parse")
-
return "authentication" if error.to_s.downcase.include?("auth") || error.to_s.downcase.include?("key")
-
-
"unknown"
-
end
-
-
# Builds response payload for storage
-
#
-
# Stores comprehensive extraction data for debugging purposes.
-
#
-
# @param result [Hash] The extraction result
-
# @return [Hash] Payload for storage
-
def build_response_payload(result)
-
payload = {}
-
-
# Provider-native request/response (raw, best-effort) for debugging.
-
# These are intentionally separate from parsed/extracted fields.
-
if result[:provider_response].present?
-
payload[:provider_response] = sanitize_payload_for_storage(result[:provider_response])
-
end
-
if result[:provider_error_response].present?
-
payload[:provider_error_response] = sanitize_payload_for_storage(result[:provider_error_response])
-
end
-
payload[:http_status] = result[:http_status] if result[:http_status].present?
-
if result[:response_headers].present?
-
payload[:response_headers] = sanitize_payload_for_storage(result[:response_headers], max_string_length: 10_000)
-
end
-
payload[:provider_endpoint] = result[:provider_endpoint] if result[:provider_endpoint].present?
-
-
# For assistant operations, store the full text response for debugging/replay.
-
if @operation_type.to_s.start_with?("assistant_") && result[:content].present?
-
payload[:text] = truncate_for_storage(result[:content], 10_000)
-
end
-
-
# Core extraction fields for job extraction
-
job_extraction_fields = %i[
-
title company job_role description about_company company_culture
-
requirements responsibilities location remote_type
-
salary_min salary_max salary_currency equity_info benefits perks
-
notes
-
]
-
-
# Store all extracted job fields with their values (truncated for long text)
-
job_extraction_fields.each do |field|
-
next unless result[field].present?
-
-
value = result[field]
-
payload[field] = case value
-
when String
-
truncate_for_display(value, 500)
-
when Array, Hash
-
value
-
else
-
value
-
end
-
end
-
-
# Always include confidence
-
payload[:confidence] = result[:confidence] if result[:confidence].present?
-
-
# Interview prep payloads: store small, structured preview fields.
-
if @operation_type.to_s.start_with?("interview_prep_")
-
payload[:match_label] = result[:match_label] if result[:match_label].present?
-
payload[:strong_in] = Array(result[:strong_in]).first(8) if result[:strong_in].present?
-
payload[:partial_in] = Array(result[:partial_in]).first(8) if result[:partial_in].present?
-
payload[:missing_or_risky] = Array(result[:missing_or_risky]).first(8) if result[:missing_or_risky].present?
-
payload[:focus_areas_count] = Array(result.dig(:focus_areas)).size if result[:focus_areas].present?
-
payload[:questions_count] = Array(result.dig(:questions)).size if result[:questions].present?
-
payload[:strengths_count] = Array(result.dig(:strengths)).size if result[:strengths].present?
-
end
-
-
# Include skills summary for resume extraction
-
if result[:skills].is_a?(Array)
-
payload[:skills_count] = result[:skills].size
-
payload[:skills_preview] = result[:skills].first(5).map { |s| s[:name] rescue s.to_s }
-
end
-
-
# Include raw LLM response if available (truncated but longer for debugging)
-
if result[:raw_response].present?
-
payload[:raw_response] = truncate_for_storage(result[:raw_response], 10_000)
-
end
-
-
# Include error info
-
payload[:error] = result[:error] if result[:error].present?
-
-
# Include any custom sections
-
payload[:custom_sections] = result[:custom_sections] if result[:custom_sections].present?
-
-
payload
-
end
-
-
# Builds request payload for storage
-
#
-
# Captures both the "prompt string" and the provider-native request payload
-
# (if the caller/provider provides it).
-
#
-
# @param prompt [String, nil]
-
# @param result [Hash, nil]
-
# @return [Hash]
-
def build_request_payload(prompt:, result: nil)
-
payload = {}
-
payload[:prompt] = truncate_for_storage(prompt) if prompt.present?
-
-
if result.is_a?(Hash)
-
if result[:provider_request].present?
-
payload[:provider_request] = sanitize_payload_for_storage(result[:provider_request])
-
end
-
payload[:provider_endpoint] = result[:provider_endpoint] if result[:provider_endpoint].present?
-
end
-
-
payload
-
end
-
-
# Attempts to capture a provider/client response from raised exceptions (best-effort).
-
#
-
# @param exception [Exception]
-
# @return [Hash]
-
def build_exception_response_payload(exception)
-
payload = {
-
exception_class: exception.class.name,
-
exception_message: exception.message
-
}
-
-
if exception.respond_to?(:response)
-
payload[:exception_response] = sanitize_payload_for_storage(exception.response, max_string_length: 10_000)
-
end
-
if exception.respond_to?(:status)
-
payload[:http_status] = exception.status
-
end
-
-
payload.compact
-
rescue StandardError
-
{ exception_class: exception.class.name, exception_message: exception.message }
-
end
-
-
# Sanitizes nested payloads for JSONB storage and UI rendering.
-
#
-
# - Truncates long strings
-
# - Converts SDK objects via `to_h` when available
-
# - Limits recursion depth to avoid pathological payloads
-
#
-
# @param value [Object]
-
# @param max_string_length [Integer]
-
# @param max_depth [Integer]
-
# @param depth [Integer]
-
# @return [Object]
-
def sanitize_payload_for_storage(value, max_string_length: 50_000, max_depth: 8, depth: 0)
-
return nil if value.nil?
-
return "[TRUNCATED: max_depth=#{max_depth}]" if depth >= max_depth
-
-
if value.is_a?(String)
-
return truncate_for_storage(value, max_string_length)
-
end
-
-
if value.is_a?(Numeric) || value == true || value == false
-
return value
-
end
-
-
if value.is_a?(Hash)
-
return value.to_h.each_with_object({}) do |(k, v), acc|
-
acc[k.to_s] = sanitize_payload_for_storage(v, max_string_length: max_string_length, max_depth: max_depth, depth: depth + 1)
-
end
-
end
-
-
if value.is_a?(Array)
-
return value.first(200).map { |v| sanitize_payload_for_storage(v, max_string_length: max_string_length, max_depth: max_depth, depth: depth + 1) }
-
end
-
-
if value.respond_to?(:to_h)
-
return sanitize_payload_for_storage(value.to_h, max_string_length: max_string_length, max_depth: max_depth, depth: depth + 1)
-
end
-
-
sanitize_payload_for_storage(value.to_s, max_string_length: max_string_length, max_depth: max_depth, depth: depth + 1)
-
end
-
-
# Truncates content for display in UI
-
#
-
# @param content [String, nil] The content to truncate
-
# @param max_length [Integer] Maximum length
-
# @return [String, nil] Truncated content
-
def truncate_for_display(content, max_length = 500)
-
return nil if content.nil?
-
return content if content.length <= max_length
-
-
content[0...max_length] + "..."
-
end
-
-
# Extracts field names that were successfully populated
-
#
-
# @param result [Hash] The extraction result
-
# @return [Array<String>] Field names
-
def extract_field_names(result)
-
# Common job extraction fields
-
job_fields = %i[
-
title company job_role description requirements responsibilities
-
location remote_type salary_min salary_max salary_currency
-
equity_info benefits perks
-
]
-
-
# Email extraction fields
-
email_fields = %i[
-
company_name job_role_title job_url recruiter_info key_details
-
all_links is_forwarded original_source
-
]
-
-
# Resume extraction fields
-
resume_fields = %i[
-
skills summary strengths domains
-
]
-
-
all_fields = job_fields + email_fields + resume_fields
-
all_fields.select { |f| result[f].present? }.map(&:to_s)
-
end
-
-
# Truncates content for storage to prevent bloat
-
#
-
# @param content [String, nil] The content to truncate
-
# @param max_length [Integer] Maximum length
-
# @return [String, nil] Truncated content
-
def truncate_for_storage(content, max_length = 50_000)
-
return nil if content.nil?
-
return content if content.length <= max_length
-
-
content[0...max_length] + "\n\n[TRUNCATED - original length: #{content.length}]"
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Ai
-
# Centralized error reporting for AI/Assistant flows.
-
#
-
# Use this instead of ad-hoc `Rails.logger.error` so we consistently capture
-
# exceptions with enough context to debug: thread/turn/trace/provider/model/log.
-
class ErrorReporter
-
# @param exception [Exception]
-
# @param operation [String, Symbol]
-
# @param provider [String, nil]
-
# @param model [String, nil]
-
# @param user [User, nil]
-
# @param thread [Assistant::ChatThread, nil]
-
# @param turn [Assistant::Turn, nil]
-
# @param trace_id [String, nil]
-
# @param llm_api_log_id [Integer, nil]
-
# @param extra [Hash]
-
def self.notify(exception, operation:, provider: nil, model: nil, user: nil, thread: nil, turn: nil, trace_id: nil, llm_api_log_id: nil, extra: {})
-
ai_context = {
-
operation: operation.to_s,
-
provider_name: provider,
-
model_identifier: model,
-
user_id: user&.id,
-
thread_id: thread&.id,
-
thread_uuid: thread&.respond_to?(:uuid) ? thread.uuid : nil,
-
turn_id: turn&.id,
-
trace_id: trace_id,
-
llm_api_log_id: llm_api_log_id
-
}.merge(extra.to_h).compact
-
-
ExceptionNotifier.notify_ai_error(exception, ai_context)
-
rescue StandardError => e
-
Rails.logger.error("[Ai::ErrorReporter] Failed to notify: #{e.class}: #{e.message}")
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Ai
-
# Service for building LLM prompts from prompt templates and variables.
-
#
-
# Uses the active prompt record when available; otherwise falls back to the
-
# class-level default prompt template and performs simple variable substitution.
-
#
-
# @example
-
# prompt = Ai::PromptBuilderService.new(
-
# prompt_class: Ai::EmailExtractionPrompt,
-
# variables: { subject: "Hello", body: "..." }
-
# ).run
-
class PromptBuilderService
-
# @param prompt_class [Class] Prompt class (e.g., Ai::EmailExtractionPrompt)
-
# @param variables [Hash] Variables used for prompt substitution
-
def initialize(prompt_class:, variables:)
-
@prompt_class = prompt_class
-
@variables = variables || {}
-
validate!
-
end
-
-
# Builds the prompt string.
-
#
-
# @return [String]
-
def run
-
template_record = prompt_class.active_prompt
-
return template_record.build_prompt(variables) if template_record
-
-
build_from_default_template
-
end
-
-
private
-
-
attr_reader :prompt_class, :variables
-
-
def validate!
-
return if prompt_class.respond_to?(:active_prompt) && prompt_class.respond_to?(:default_prompt_template)
-
-
raise ArgumentError, "prompt_class must respond to active_prompt and default_prompt_template"
-
end
-
-
def build_from_default_template
-
prompt = prompt_class.default_prompt_template.dup
-
variables.each do |key, value|
-
prompt.gsub!("{{#{key}}}", value.to_s)
-
end
-
prompt
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Ai
-
# Service for running LLM providers with standardized logging and fallback.
-
#
-
# Wraps provider execution with ApiLoggerService.record, captures
-
# provider response metadata, and supports custom parsing/acceptance.
-
#
-
# @example
-
# runner = Ai::ProviderRunnerService.new(
-
# provider_chain: providers,
-
# prompt: prompt,
-
# content_size: content_size,
-
# system_message: system_message,
-
# provider_for: ->(name) { provider_for(name) },
-
# logger_builder: ->(name, provider) { build_logger(name, provider) }
-
# )
-
# result = runner.run do |response|
-
# parsed = parse_response(response[:content])
-
# log_data = { confidence: parsed[:confidence_score] }
-
# accept = parsed[:confidence_score].to_f >= 0.6
-
# [ parsed, log_data, accept ]
-
# end
-
#
-
# @return [Hash] result with success/error status
-
class ProviderRunnerService < ApplicationService
-
# @param provider_chain [Array<String>] Ordered provider names
-
# @param prompt [String] Prompt text
-
# @param content_size [Integer] Content size for logging
-
# @param system_message [String, nil] System message for providers
-
# @param provider_for [Proc] Proc that returns provider instance
-
# @param logger_builder [Proc] Proc that returns ApiLoggerService
-
# @param run_options [Hash] Additional provider.run options
-
# @param on_exception [Proc, nil] Optional exception handler
-
def initialize(
-
provider_chain:,
-
prompt:,
-
content_size:,
-
system_message: nil,
-
provider_for:,
-
logger_builder:,
-
run_options: {},
-
on_exception: nil,
-
on_rate_limit: nil,
-
on_error: nil,
-
operation: nil,
-
loggable: nil,
-
user: nil,
-
error_context: nil
-
)
-
@provider_chain = provider_chain
-
@prompt = prompt
-
@content_size = content_size
-
@system_message = system_message
-
@provider_for = provider_for
-
@logger_builder = logger_builder
-
@run_options = run_options
-
@on_exception = on_exception
-
@on_rate_limit = on_rate_limit
-
@on_error = on_error
-
@operation = operation
-
@loggable = loggable
-
@user = user
-
@error_context = error_context
-
end
-
-
# Runs providers in order until one is accepted.
-
#
-
# @yieldparam response [Hash] Provider response
-
# @yieldreturn [Array] [parsed, log_data, accept]
-
# @return [Hash] Result with status, parsed, log id, and metadata
-
def run
-
provider_chain.each do |provider_name|
-
provider = provider_for.call(provider_name)
-
next unless provider
-
-
unless provider.available?
-
log_unavailable_provider(provider_name, provider)
-
next
-
end
-
-
response_model = nil
-
accept = true
-
parsed = nil
-
logger = logger_builder.call(provider_name, provider)
-
-
result = logger.record(prompt: prompt, content_size: content_size) do
-
response = provider.run(prompt, **provider_run_options)
-
response_model = response[:model]
-
-
if response[:rate_limit]
-
on_rate_limit&.call(response, provider_name, logger)
-
next error_log_data(
-
response,
-
error: "rate_limited",
-
rate_limit: true
-
)
-
end
-
-
if response[:error]
-
on_error&.call(response, provider_name, logger)
-
next error_log_data(
-
response,
-
error: response[:error],
-
error_type: response[:error_type]
-
)
-
end
-
-
parsed, log_data, accept = yield(response)
-
log_data ||= {}
-
log_data.merge(standard_response_data(response))
-
end
-
-
next if result[:rate_limit] || result[:error]
-
next if accept == false
-
-
model_name = response_model || (provider.respond_to?(:model_name) ? provider.model_name : "unknown")
-
return {
-
success: true,
-
provider: provider_name,
-
model: model_name,
-
parsed: parsed,
-
llm_api_log_id: result[:llm_api_log_id],
-
latency_ms: result[:latency_ms],
-
result: result
-
}
-
rescue StandardError => e
-
handle_exception(e, provider_name, logger)
-
next
-
end
-
-
{ success: false, error: "All providers failed" }
-
end
-
-
private
-
-
attr_reader :provider_chain,
-
:prompt,
-
:content_size,
-
:system_message,
-
:provider_for,
-
:logger_builder,
-
:run_options,
-
:on_exception,
-
:on_rate_limit,
-
:on_error,
-
:operation,
-
:loggable,
-
:user,
-
:error_context
-
-
def log_unavailable_provider(provider_name, provider)
-
logger = logger_builder.call(provider_name, provider)
-
logger.record_result(
-
{
-
error: "provider_unavailable",
-
error_type: "configuration",
-
provider_endpoint: (provider.respond_to?(:provider_endpoint) ? provider.provider_endpoint : nil)
-
}.compact,
-
latency_ms: 0,
-
prompt: prompt,
-
content_size: content_size
-
)
-
rescue StandardError => e
-
# best-effort only; never block the runner
-
handle_exception(e, provider_name, logger)
-
nil
-
end
-
-
# Builds options for provider.run
-
#
-
# @return [Hash]
-
def provider_run_options
-
base = run_options.dup
-
base[:system_message] = system_message if system_message.present?
-
base
-
end
-
-
# Builds standard log data for error results.
-
#
-
# @param response [Hash]
-
# @param error [String]
-
# @param error_type [String, nil]
-
# @param rate_limit [Boolean]
-
# @return [Hash]
-
def error_log_data(response, error:, error_type: nil, rate_limit: false)
-
standard_response_data(response).merge(
-
error: error,
-
error_type: error_type,
-
rate_limit: rate_limit
-
)
-
end
-
-
# Builds standard log data for responses.
-
#
-
# @param response [Hash]
-
# @return [Hash]
-
def standard_response_data(response)
-
{
-
input_tokens: response[:input_tokens],
-
output_tokens: response[:output_tokens],
-
raw_response: response[:content],
-
provider_request: response[:provider_request],
-
provider_response: response[:provider_response],
-
provider_error_response: response[:provider_error_response],
-
http_status: response[:http_status],
-
response_headers: response[:response_headers],
-
provider_endpoint: response[:provider_endpoint]
-
}
-
end
-
-
def handle_exception(exception, provider_name, logger)
-
return on_exception.call(exception, provider_name, logger) if on_exception
-
-
latency_ms = logger&.log&.latency_ms
-
model_name = logger&.log&.model
-
extra = { processing_time_ms: latency_ms }.merge(error_context.to_h).compact
-
-
if operation.present?
-
notify_ai_error(
-
exception,
-
operation: operation,
-
provider: provider_name,
-
model: model_name,
-
loggable: loggable,
-
severity: extra.delete(:severity) || "error",
-
**extra
-
)
-
else
-
notify_error(
-
exception,
-
context: "provider_runner",
-
severity: extra.delete(:severity) || "error",
-
user: user,
-
**extra
-
)
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Ai
-
# Service for parsing LLM responses into JSON data.
-
#
-
# Supports extracting JSON blocks embedded in text and optional symbolization.
-
#
-
# @example
-
# parsed = Ai::ResponseParserService.new(response_text).parse(symbolize: true)
-
class ResponseParserService
-
# @param response_text [String] Raw LLM response
-
# @param json_only [Boolean] If true, only parse JSON block
-
def initialize(response_text, json_only: true)
-
@response_text = response_text.to_s
-
@json_only = json_only
-
end
-
-
# Parses the response into a Hash.
-
#
-
# @param symbolize [Boolean] Whether to symbolize keys
-
# @return [Hash, nil]
-
def parse(symbolize: false)
-
return nil if response_text.blank?
-
-
payload = json_only ? extract_json(response_text) : response_text
-
return nil if payload.blank?
-
-
JSON.parse(payload, symbolize_names: symbolize)
-
rescue JSON::ParserError
-
nil
-
end
-
-
private
-
-
attr_reader :response_text, :json_only
-
-
def extract_json(text)
-
match = text.match(/\{.*\}/m)
-
match&.[](0)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module ApiFetchers
-
# Base fetcher class for job board API integrations
-
#
-
# Provides common functionality for making API requests
-
# and parsing responses.
-
#
-
# @abstract Subclass and override {#fetch} to implement
-
class BaseFetcher < ApplicationService
-
# Logs a structured event for API operations
-
#
-
# @param [String] event_name The event name
-
# @param [Hash] data Additional event data
-
def log_event(event_name, data = {})
-
base_data = {
-
event: event_name,
-
service: self.class.name
-
}
-
Rails.logger.info(base_data.merge(data).to_json)
-
end
-
-
# Logs an error for API operations
-
#
-
# @param [String] message The error message
-
# @param [Exception, nil] exception Optional exception object
-
def log_error(message, exception = nil)
-
error_data = {
-
error: message,
-
service: self.class.name
-
}
-
-
if exception
-
error_data.merge!(
-
exception: exception.class.name,
-
message: exception.message,
-
backtrace: exception.backtrace&.first(5)
-
)
-
end
-
-
Rails.logger.error(error_data.to_json)
-
end
-
# Fetches job listing data from the API
-
#
-
# @param [String] url The job listing URL
-
# @param [String] job_id The job ID
-
# @param [String] company_slug The company identifier
-
# @return [Hash] Standardized job data
-
# @raise [NotImplementedError] Must be implemented by subclass
-
def fetch(url:, job_id: nil, company_slug: nil)
-
raise NotImplementedError, "#{self.class} must implement #fetch"
-
end
-
-
protected
-
-
# Makes an API request with standard headers and error handling
-
#
-
# @param [String] url The API endpoint URL
-
# @param [Hash] headers Additional headers
-
# @return [HTTParty::Response] The response
-
def make_request(url, headers: {})
-
HTTParty.get(
-
url,
-
headers: default_headers.merge(headers),
-
timeout: 30,
-
open_timeout: 10,
-
follow_redirects: true
-
)
-
rescue => e
-
log_error("API request failed", e)
-
raise
-
end
-
-
# Returns default headers for API requests
-
#
-
# @return [Hash] Default headers
-
def default_headers
-
{
-
"User-Agent" => "GleaniaBot/1.0 (+https://gleania.com/bot)",
-
"Accept" => "application/json"
-
}
-
end
-
-
# Normalizes the API response to our standard format
-
#
-
# @param [Hash] api_data The raw API response
-
# @return [Hash] Standardized job data
-
def normalize_response(api_data)
-
{
-
title: api_data[:title],
-
description: api_data[:description],
-
requirements: api_data[:requirements],
-
responsibilities: api_data[:responsibilities],
-
location: api_data[:location],
-
remote_type: api_data[:remote_type] || "on_site",
-
salary_min: api_data[:salary_min],
-
salary_max: api_data[:salary_max],
-
salary_currency: api_data[:salary_currency] || "USD",
-
equity_info: api_data[:equity_info],
-
benefits: api_data[:benefits],
-
perks: api_data[:perks],
-
custom_sections: api_data[:custom_sections] || {},
-
confidence: 1.0, # API data is high confidence
-
extraction_method: "api",
-
provider: provider_name
-
}
-
end
-
-
# Returns the provider name
-
#
-
# @return [String] Provider name
-
def provider_name
-
self.class.name.demodulize.gsub("Fetcher", "").downcase
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
require "cgi"
-
-
module ApiFetchers
-
# Greenhouse API fetcher for job listings
-
#
-
# Uses Greenhouse's public job board API to fetch job listing data.
-
# API docs: https://developers.greenhouse.io/job-board.html
-
class GreenhouseFetcher < BaseFetcher
-
BASE_URL = "https://boards-api.greenhouse.io/v1/boards"
-
-
# Fetches job listing from Greenhouse API
-
#
-
# @param [String] url The job listing URL
-
# @param [String] job_id The Greenhouse job ID
-
# @param [String] company_slug The company board token/slug
-
# @return [Hash] Standardized job data
-
def fetch(url:, job_id: nil, company_slug: nil)
-
return nil unless Setting.greenhouse_enabled?
-
-
# If we don't have required params, try to extract from URL
-
company_slug ||= extract_company_from_url(url)
-
job_id ||= extract_job_id_from_url(url)
-
-
raise ArgumentError, "Cannot fetch without company slug" unless company_slug
-
raise ArgumentError, "Cannot fetch without job ID" unless job_id
-
-
log_event("api_extraction_started", {
-
board_type: "greenhouse",
-
company_slug: company_slug,
-
job_id: job_id,
-
url: url
-
})
-
-
api_url = "#{BASE_URL}/#{company_slug}/jobs/#{job_id}"
-
response = make_request(api_url)
-
-
if response.success?
-
result = parse_greenhouse_response(response.parsed_response)
-
log_event("api_extraction_succeeded", {
-
board_type: "greenhouse",
-
confidence: result[:confidence]
-
})
-
result
-
else
-
log_event("api_extraction_failed", {
-
board_type: "greenhouse",
-
error: "API request failed: #{response.code}",
-
http_status: response.code
-
})
-
{ error: "API request failed: #{response.code}", confidence: 0.0 }
-
end
-
rescue => e
-
log_error("Greenhouse API fetch failed", e)
-
notify_error(
-
e,
-
context: "greenhouse_api_fetch",
-
severity: "error",
-
url: url,
-
company_slug: company_slug,
-
job_id: job_id
-
)
-
{ error: e.message, confidence: 0.0 }
-
end
-
-
private
-
-
# Parses Greenhouse API response to our standard format
-
#
-
# @param [Hash] data The Greenhouse API response
-
# @return [Hash] Standardized job data
-
def parse_greenhouse_response(data)
-
location_name = data.dig("location", "name")
-
content_html = decode_html(data["content"])
-
-
# Determine remote type from location
-
remote_type = if location_name&.downcase&.include?("remote")
-
"remote"
-
elsif location_name&.downcase&.include?("hybrid")
-
"hybrid"
-
else
-
"on_site"
-
end
-
-
normalize_response(
-
title: data["title"],
-
description: content_html,
-
requirements: extract_section(content_html, "requirements"),
-
responsibilities: extract_section(content_html, "responsibilities"),
-
location: location_name,
-
remote_type: remote_type,
-
salary_min: nil, # Greenhouse doesn't always expose salary in public API
-
salary_max: nil,
-
salary_currency: "USD",
-
custom_sections: build_custom_sections(data)
-
).merge(
-
company: data["company_name"]
-
)
-
end
-
-
# Strips HTML tags from content
-
#
-
# @param [String] html HTML content
-
# @return [String] Plain text
-
def strip_html(html)
-
return nil if html.blank?
-
-
Nokogiri::HTML.fragment(html.to_s).text.to_s.gsub(/\s+/, " ").strip
-
end
-
-
# Extracts a specific section from job content
-
#
-
# @param [String] content The full job content
-
# @param [String] section_name The section to extract
-
# @return [String, nil] Extracted section or nil
-
def extract_section(content, section_name)
-
return nil if content.blank?
-
-
# Try to find section by common headers
-
patterns = [
-
/<h[23][^>]*>#{section_name}<\/h[23]>(.*?)(?:<h[23]|$)/mi,
-
/<strong>#{section_name}<\/strong>(.*?)(?:<strong>|$)/mi
-
]
-
-
patterns.each do |pattern|
-
match = content.match(pattern)
-
return strip_html(match[1]) if match
-
end
-
-
nil
-
end
-
-
def decode_html(text)
-
return "" if text.blank?
-
-
CGI.unescapeHTML(text.to_s)
-
rescue
-
text.to_s
-
end
-
-
# Builds custom sections from Greenhouse data
-
#
-
# @param [Hash] data The Greenhouse response
-
# @return [Hash] Custom sections
-
def build_custom_sections(data)
-
sections = {}
-
-
if data["departments"]&.any?
-
sections["departments"] = data["departments"].map { |d| d["name"] }
-
end
-
-
if data["offices"]&.any?
-
sections["offices"] = data["offices"].map { |o| o["name"] }
-
end
-
-
sections["updated_at"] = data["updated_at"] if data["updated_at"]
-
sections["absolute_url"] = data["absolute_url"] if data["absolute_url"]
-
-
sections
-
end
-
-
# Extracts company slug from URL
-
#
-
# @param [String] url The job listing URL
-
# @return [String, nil] Company slug
-
def extract_company_from_url(url)
-
match = url.match(%r{boards\.greenhouse\.io/([^/]+)})
-
match ? match[1] : nil
-
end
-
-
# Extracts job ID from URL
-
#
-
# @param [String] url The job listing URL
-
# @return [String, nil] Job ID
-
def extract_job_id_from_url(url)
-
match = url.match(%r{/jobs?/(\d+)}) || url.match(/gh_jid=([^&]+)/)
-
match ? match[1] : nil
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module ApiFetchers
-
# Lever API fetcher for job listings
-
#
-
# Uses Lever's public postings API to fetch job listing data.
-
# API docs: https://github.com/lever/postings-api
-
class LeverFetcher < BaseFetcher
-
BASE_URL = "https://api.lever.co/v0/postings"
-
-
# Fetches job listing from Lever API
-
#
-
# @param [String] url The job listing URL
-
# @param [String] job_id The Lever posting ID
-
# @param [String] company_slug The company identifier
-
# @return [Hash] Standardized job data
-
def fetch(url:, job_id: nil, company_slug: nil)
-
return nil unless Setting.lever_enabled?
-
-
# If we don't have required params, try to extract from URL
-
company_slug ||= extract_company_from_url(url)
-
job_id ||= extract_job_id_from_url(url)
-
-
raise ArgumentError, "Cannot fetch without company slug" unless company_slug
-
-
log_event("api_extraction_started", {
-
board_type: "lever",
-
company_slug: company_slug,
-
job_id: job_id,
-
url: url
-
})
-
-
api_url = if job_id
-
"#{BASE_URL}/#{company_slug}/#{job_id}"
-
else
-
# Fetch all postings and find by URL matching
-
"#{BASE_URL}/#{company_slug}"
-
end
-
-
response = make_request(api_url)
-
-
if response.success?
-
data = response.parsed_response
-
-
result = if job_id
-
parse_lever_response(data)
-
else
-
# Find matching posting from list
-
posting = data.find { |p| p["hostedUrl"] == url }
-
posting ? parse_lever_response(posting) : { error: "Job not found", confidence: 0.0 }
-
end
-
-
if result[:error]
-
log_event("api_extraction_failed", {
-
board_type: "lever",
-
error: result[:error]
-
})
-
else
-
log_event("api_extraction_succeeded", {
-
board_type: "lever",
-
confidence: result[:confidence]
-
})
-
end
-
-
result
-
else
-
log_event("api_extraction_failed", {
-
board_type: "lever",
-
error: "API request failed: #{response.code}",
-
http_status: response.code
-
})
-
{ error: "API request failed: #{response.code}", confidence: 0.0 }
-
end
-
rescue => e
-
log_error("Lever API fetch failed", e)
-
notify_error(
-
e,
-
context: "lever_api_fetch",
-
severity: "error",
-
url: url,
-
company_slug: company_slug,
-
job_id: job_id
-
)
-
{ error: e.message, confidence: 0.0 }
-
end
-
-
private
-
-
# Parses Lever API response to our standard format
-
#
-
# @param [Hash] data The Lever API response
-
# @return [Hash] Standardized job data
-
def parse_lever_response(data)
-
location = data.dig("categories", "location") || data.dig("location")
-
-
# Determine remote type
-
remote_type = if data["workplaceType"] == "remote"
-
"remote"
-
elsif data["workplaceType"] == "hybrid"
-
"hybrid"
-
else
-
"on_site"
-
end
-
-
# Combine description sections
-
description = [
-
data["description"],
-
data["descriptionPlain"]
-
].compact.first
-
-
normalize_response(
-
title: data["text"],
-
description: description,
-
requirements: extract_lists(data["lists"], [ "requirements", "qualifications" ]),
-
responsibilities: extract_lists(data["lists"], [ "responsibilities", "role" ]),
-
location: location,
-
remote_type: remote_type,
-
salary_min: nil, # Lever doesn't always expose salary in public API
-
salary_max: nil,
-
salary_currency: "USD",
-
custom_sections: build_custom_sections(data)
-
)
-
end
-
-
# Extracts content from lists by matching keys
-
#
-
# @param [Array] lists The lists array from Lever
-
# @param [Array] keys Keys to match
-
# @return [String, nil] Combined list content
-
def extract_lists(lists, keys)
-
return nil unless lists&.any?
-
-
matching = lists.select { |list|
-
list_text = list["text"].to_s.downcase
-
keys.any? { |key| list_text.include?(key) }
-
}
-
-
return nil if matching.empty?
-
-
matching.map { |list|
-
content = list["content"]
-
# Clean up HTML if present
-
content.is_a?(String) ? content.gsub(/<[^>]+>/, "\n").strip : content
-
}.join("\n\n")
-
end
-
-
# Builds custom sections from Lever data
-
#
-
# @param [Hash] data The Lever response
-
# @return [Hash] Custom sections
-
def build_custom_sections(data)
-
sections = {}
-
-
if data["categories"]
-
sections["team"] = data["categories"]["team"]
-
sections["department"] = data["categories"]["department"]
-
sections["commitment"] = data["categories"]["commitment"]
-
end
-
-
sections["apply_url"] = data["applyUrl"] if data["applyUrl"]
-
sections["hosted_url"] = data["hostedUrl"] if data["hostedUrl"]
-
sections["created_at"] = data["createdAt"] if data["createdAt"]
-
-
# Include additional lists that we didn't categorize
-
if data["lists"]&.any?
-
other_lists = data["lists"].reject { |list|
-
text = list["text"].to_s.downcase
-
text.include?("requirement") || text.include?("responsibilit") ||
-
text.include?("qualif") || text.include?("role")
-
}
-
-
sections["additional_info"] = other_lists.map { |list|
-
{ "title" => list["text"], "content" => list["content"] }
-
} if other_lists.any?
-
end
-
-
sections
-
end
-
-
# Extracts company slug from URL
-
#
-
# @param [String] url The job listing URL
-
# @return [String, nil] Company slug
-
def extract_company_from_url(url)
-
match = url.match(%r{jobs\.lever\.co/([^/]+)})
-
match ? match[1] : nil
-
end
-
-
# Extracts job ID from URL
-
#
-
# @param [String] url The job listing URL
-
# @return [String, nil] Job ID
-
def extract_job_id_from_url(url)
-
match = url.match(%r{jobs\.lever\.co/[^/]+/([^/\?]+)})
-
match ? match[1] : nil
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# Base class for all application services.
-
#
-
# Provides common error notification, logging, and utility methods.
-
#
-
# @example Basic service
-
# class MyService < ApplicationService
-
# def call
-
# # do work
-
# rescue StandardError => e
-
# notify_error(e, context: "my_service")
-
# raise
-
# end
-
# end
-
#
-
# @example AI-related service
-
# class MyAiService < ApplicationService
-
# def call
-
# result = call_llm_provider
-
# rescue StandardError => e
-
# notify_ai_error(e, operation: "my_ai_operation", provider: "openai")
-
# raise
-
# end
-
# end
-
#
-
class ApplicationService
-
# Notifies of a general error with context
-
#
-
# @param exception [Exception] The exception to report
-
# @param context [String] Error context (e.g., 'payment', 'sync')
-
# @param severity [String] Severity level ('error', 'warning', 'info')
-
# @param user [User, nil] User associated with the error
-
# @param extra [Hash] Additional context
-
# @return [void]
-
def notify_error(exception, context:, severity: "error", user: nil, **extra)
-
user_info = case user
-
when User
-
{ id: user.id, email: user.email_address }
-
when Hash
-
user
-
end
-
-
ExceptionNotifier.notify(exception, {
-
context: context,
-
severity: severity,
-
user: user_info
-
}.merge(extra).compact)
-
end
-
-
# Notifies of an AI-related error with AI-specific context
-
#
-
# @param exception [Exception] The exception to report
-
# @param operation [String, Symbol] AI operation type
-
# @param provider [String, nil] LLM provider name
-
# @param model [String, nil] Model identifier
-
# @param loggable [ApplicationRecord, nil] The record being processed
-
# @param severity [String] Severity level
-
# @param extra [Hash] Additional context
-
# @return [void]
-
def notify_ai_error(exception, operation:, provider: nil, model: nil, loggable: nil, severity: "error", **extra)
-
ai_context = {
-
operation: operation.to_s,
-
provider_name: provider,
-
model_identifier: model,
-
analyzable_type: loggable&.class&.name,
-
analyzable_id: loggable&.id,
-
severity: severity
-
}.merge(extra.to_h).compact
-
-
ExceptionNotifier.notify_ai_error(exception, ai_context)
-
end
-
-
# Logs a warning message with service context
-
#
-
# @param message [String] The warning message
-
# @return [void]
-
def log_warning(message)
-
Rails.logger.warn("[#{self.class.name}] #{message}")
-
end
-
-
# Logs an error message with service context
-
#
-
# @param message [String] The error message
-
# @return [void]
-
def log_error(message)
-
Rails.logger.error("[#{self.class.name}] #{message}")
-
end
-
-
# Logs an info message with service context
-
#
-
# @param message [String] The info message
-
# @return [void]
-
def log_info(message)
-
Rails.logger.info("[#{self.class.name}] #{message}")
-
end
-
-
# Safely executes a block, catching and logging errors without re-raising
-
#
-
# @param fallback [Object] Value to return if block fails
-
# @param context [String, nil] Error context for notification (if provided, error is notified)
-
# @yield The block to execute
-
# @return [Object] Block result or fallback value
-
def safely(fallback: nil, context: nil)
-
yield
-
rescue StandardError => e
-
log_warning("#{e.class}: #{e.message}")
-
notify_error(e, context: context) if context
-
fallback
-
end
-
-
# Class-level call method for convenient invocation
-
#
-
# @example
-
# MyService.call(arg1, arg2)
-
# # equivalent to: MyService.new(arg1, arg2).call
-
#
-
def self.call(...)
-
new(...).call
-
end
-
end
-
# frozen_string_literal: true
-
-
# Service for generating timeline data for an interview application
-
#
-
# @example
-
# service = ApplicationTimelineService.new(interview_application)
-
# timeline = service.generate
-
#
-
class ApplicationTimelineService
-
# Initialize the service with an interview application
-
#
-
# @param [InterviewApplication] interview_application The application to generate timeline for
-
def initialize(interview_application)
-
@application = interview_application
-
end
-
-
# Generates timeline data for the application
-
#
-
# @return [Array<Hash>] Array of timeline events
-
def generate
-
events = []
-
-
# Add application submission event
-
events << application_event
-
-
# Add interview round events
-
events.concat(interview_round_events)
-
-
# Add email events
-
events.concat(email_events)
-
-
# Add company feedback event
-
events << company_feedback_event if @application.company_feedback.present?
-
-
# Add status change events (if we track them in the future)
-
# events.concat(status_change_events)
-
-
# Sort by date
-
events.sort_by { |e| e[:date] || Time.current }
-
end
-
-
# Returns timeline as grouped by month
-
#
-
# @return [Hash] Timeline events grouped by month
-
def generate_grouped
-
generate.group_by { |event| event[:date].beginning_of_month }
-
end
-
-
# Returns summary statistics for the timeline
-
#
-
# @return [Hash] Summary statistics
-
def summary
-
{
-
total_events: generate.count,
-
total_rounds: @application.interview_rounds.count,
-
completed_rounds: @application.completed_rounds_count,
-
pending_rounds: @application.pending_rounds_count,
-
total_emails: @application.synced_emails.count,
-
days_since_application: days_since_application,
-
has_feedback: @application.has_company_feedback?
-
}
-
end
-
-
private
-
-
def application_event
-
{
-
type: :application,
-
title: "Applied to #{@application.job_role.title}",
-
description: "Submitted application to #{@application.company.name}",
-
date: @application.applied_at || @application.created_at,
-
icon: :document,
-
color: :blue
-
}
-
end
-
-
def interview_round_events
-
@application.interview_rounds.ordered.map do |round|
-
{
-
type: :interview_round,
-
title: round.stage_display_name,
-
description: round.interviewer_display || "Interview round",
-
date: round.completed_at || round.scheduled_at || round.created_at,
-
icon: interview_icon(round),
-
color: interview_color(round),
-
status: round.result,
-
round_id: round.id
-
}
-
end
-
end
-
-
def company_feedback_event
-
feedback = @application.company_feedback
-
{
-
type: :company_feedback,
-
title: feedback.rejection? ? "Received Rejection" : "Received Feedback",
-
description: feedback.summary.truncate(100),
-
date: feedback.received_at || feedback.created_at,
-
icon: :chat,
-
color: feedback.rejection? ? :red : :green,
-
feedback_id: feedback.id
-
}
-
end
-
-
def email_events
-
@application.synced_emails.chronological.map do |email|
-
{
-
type: :email,
-
title: email_event_title(email),
-
description: email.short_subject(80),
-
date: email.email_date || email.created_at,
-
icon: email_icon(email),
-
color: email_color(email),
-
email_id: email.id,
-
email_type: email.email_type,
-
from: email.sender_display,
-
snippet: email.snippet&.truncate(150),
-
expandable: true
-
}
-
end
-
end
-
-
def email_event_title(email)
-
case email.email_type
-
when "interview_invite"
-
"Interview Invitation"
-
when "scheduling"
-
"Scheduling Request"
-
when "application_confirmation"
-
"Application Confirmed"
-
when "rejection"
-
"Application Update"
-
when "offer"
-
"Offer Received"
-
when "assessment"
-
"Assessment Request"
-
when "follow_up"
-
"Follow Up"
-
when "thank_you"
-
"Thank You Note"
-
else
-
"Email from #{email.sender_display}"
-
end
-
end
-
-
def email_icon(email)
-
case email.email_type
-
when "interview_invite", "scheduling"
-
:calendar
-
when "application_confirmation"
-
:check_circle
-
when "rejection"
-
:x_circle
-
when "offer"
-
:gift
-
when "assessment"
-
:clipboard
-
when "follow_up", "thank_you"
-
:mail
-
else
-
:mail
-
end
-
end
-
-
def email_color(email)
-
case email.email_type
-
when "interview_invite", "scheduling"
-
:blue
-
when "application_confirmation"
-
:purple
-
when "rejection"
-
:red
-
when "offer"
-
:green
-
when "assessment"
-
:yellow
-
else
-
:gray
-
end
-
end
-
-
def interview_icon(round)
-
case round.stage.to_sym
-
when :screening then :phone
-
when :technical then :code
-
when :hiring_manager then :user
-
when :culture_fit then :users
-
else :calendar
-
end
-
end
-
-
def interview_color(round)
-
case round.result.to_sym
-
when :passed then :green
-
when :failed then :red
-
when :waitlisted then :yellow
-
else :gray
-
end
-
end
-
-
def days_since_application
-
return 0 unless @application.applied_at
-
-
(Time.current.to_date - @application.applied_at.to_date).to_i
-
end
-
end
-
# frozen_string_literal: true
-
-
module Billing
-
# Grants/revokes internal Admin/Developer access (all features enabled).
-
#
-
# Implementation notes:
-
# - Entitlement grants require an expires_at, so "no restriction" is represented
-
# as a far-future expiry.
-
# - The grant entitlements are expanded to include all known Billing::Feature keys,
-
# so the gating layer does not need wildcard logic.
-
class AdminAccessService
-
FAR_FUTURE_EXPIRY = 100.years
-
-
# @param user [User]
-
# @param actor [User, nil]
-
def initialize(user:, actor: nil)
-
@user = user
-
@actor = actor
-
end
-
-
# @return [Billing::EntitlementGrant]
-
def grant!
-
existing = active_admin_grant
-
if existing.present?
-
# Keep grants up to date as features evolve. Older grants may not include
-
# recently-added features, which would incorrectly block access.
-
desired_entitlements = build_all_entitlements
-
desired_expiry = Time.current + FAR_FUTURE_EXPIRY
-
-
if existing.entitlements != desired_entitlements || existing.expires_at < desired_expiry
-
existing.update!(
-
entitlements: desired_entitlements,
-
expires_at: desired_expiry
-
)
-
end
-
-
return existing
-
end
-
-
Billing::EntitlementGrant.create!(
-
user: user,
-
source: "admin",
-
reason: "admin_developer",
-
starts_at: Time.current,
-
expires_at: Time.current + FAR_FUTURE_EXPIRY,
-
entitlements: build_all_entitlements,
-
metadata: {
-
granted_by_user_id: actor&.id,
-
granted_by_email: actor&.email_address
-
}.compact
-
)
-
end
-
-
# @return [Integer] number of grants revoked
-
def revoke!
-
grants = Billing::EntitlementGrant.active_at(Time.current).where(user: user, source: "admin", reason: "admin_developer")
-
now = Time.current
-
-
grants.each do |g|
-
# Keep the window valid: expires_at must remain > starts_at.
-
min_expiry = g.starts_at + 1.second
-
g.update!(expires_at: [ now, min_expiry ].max)
-
end
-
-
grants.size
-
end
-
-
# @return [Boolean]
-
def active?
-
active_admin_grant.present?
-
end
-
-
private
-
-
attr_reader :user, :actor
-
-
def active_admin_grant
-
Billing::EntitlementGrant.active_at(Time.current).find_by(user: user, source: "admin", reason: "admin_developer")
-
end
-
-
def build_all_entitlements
-
Billing::Feature.all.each_with_object({}) do |feature, h|
-
# For quota features, set limit to nil (unlimited)
-
# This explicitly overrides any base plan limits
-
if feature.kind == "quota"
-
h[feature.key] = { "enabled" => true, "limit" => nil }
-
else
-
h[feature.key] = { "enabled" => true }
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Billing
-
# Read-only access layer for the billing catalog (plans/features/entitlements).
-
#
-
# This is intentionally cached because it is used by both in-app surfaces and
-
# public pricing pages. All catalog models purge this cache on commit so
-
# changes in the developer portal reflect immediately.
-
class Catalog
-
CACHE_KEY = "billing:catalog:v1"
-
CACHE_TTL = ENV.fetch("BILLING_CATALOG_CACHE_TTL", 15.seconds).to_i
-
-
class << self
-
# Returns published plans ordered for display, including entitlements.
-
#
-
# @return [Array<Billing::Plan>]
-
def published_plans
-
cached[:published_plans]
-
end
-
-
# Purges cached catalog data.
-
#
-
# @return [void]
-
def purge_cache!
-
Rails.cache.delete(CACHE_KEY)
-
end
-
-
private
-
-
def cached
-
Rails.cache.fetch(CACHE_KEY, expires_in: CACHE_TTL) do
-
plans = Billing::Plan.published.ordered.includes(plan_entitlements: :feature).to_a
-
{ published_plans: plans }
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Billing
-
# Builds a debug snapshot of billing/subscription state for a user.
-
# Intended for internal support/debug UI (developer portal).
-
#
-
# @example
-
# snapshot = Billing::DebugSnapshotService.new(user: Current.user).run
-
#
-
class DebugSnapshotService
-
# @param user [User]
-
# @param at [Time]
-
def initialize(user:, at: Time.current)
-
@user = user
-
@at = at
-
end
-
-
# @return [Hash]
-
def run
-
ent = Billing::Entitlements.for(user, at: at)
-
subscription = ent.active_subscription
-
grants = Billing::EntitlementGrant.active_at(at).where(user: user).order(created_at: :asc).to_a
-
-
{
-
generated_at: at.iso8601,
-
user: {
-
id: user.id,
-
uuid: user.uuid,
-
email: user.email_address,
-
legacy_is_admin_flag: (user.respond_to?(:is_admin) ? (user.is_admin == true) : nil)
-
},
-
plans: {
-
subscription_plan: plan_summary(ent.subscription_plan),
-
effective_plan: plan_summary(ent.effective_plan)
-
},
-
subscription: subscription_summary(subscription),
-
grants: grants.map { |g| grant_summary(g) },
-
entitlements: entitlements_summary(ent)
-
}
-
end
-
-
private
-
-
attr_reader :user, :at
-
-
# @param plan [Billing::Plan, nil]
-
# @return [Hash, nil]
-
def plan_summary(plan)
-
return nil if plan.nil?
-
-
{
-
id: plan.id,
-
uuid: plan.uuid,
-
key: plan.key,
-
name: plan.name,
-
plan_type: plan.plan_type,
-
interval: plan.interval,
-
amount_cents: plan.amount_cents,
-
currency: plan.currency,
-
published: plan.published
-
}
-
end
-
-
# @param subscription [Billing::Subscription, nil]
-
# @return [Hash, nil]
-
def subscription_summary(subscription)
-
return nil if subscription.nil?
-
-
{
-
id: subscription.id,
-
uuid: subscription.uuid,
-
provider: subscription.provider,
-
status: subscription.status,
-
plan: plan_summary(subscription.plan),
-
current_period_starts_at: subscription.current_period_starts_at&.iso8601,
-
current_period_ends_at: subscription.current_period_ends_at&.iso8601,
-
trial_ends_at: subscription.trial_ends_at&.iso8601,
-
cancel_at_period_end: subscription.cancel_at_period_end,
-
updated_at: subscription.updated_at&.iso8601
-
}
-
end
-
-
# @param grant [Billing::EntitlementGrant]
-
# @return [Hash]
-
def grant_summary(grant)
-
entitlement_keys = grant.entitlements.is_a?(Hash) ? grant.entitlements.keys.sort : []
-
-
{
-
id: grant.id,
-
uuid: grant.uuid,
-
source: grant.source,
-
reason: grant.reason,
-
plan: plan_summary(grant.plan),
-
starts_at: grant.starts_at&.iso8601,
-
expires_at: grant.expires_at&.iso8601,
-
active: grant.active?(time: at),
-
entitlements_keys: entitlement_keys,
-
entitlements_size: grant.entitlements.is_a?(Hash) ? grant.entitlements.size : nil,
-
entitlements_sample: entitlements_sample(grant.entitlements)
-
}
-
end
-
-
# @param entitlements [Object]
-
# @return [Hash]
-
def entitlements_sample(entitlements)
-
return {} unless entitlements.is_a?(Hash)
-
-
keys = %w[
-
interview_prepare_access
-
interview_prepare_refreshes
-
round_prep_access
-
round_prep_generations
-
ai_summaries
-
interviews
-
]
-
-
entitlements.slice(*keys)
-
end
-
-
# @param ent [Billing::Entitlements]
-
# @return [Hash]
-
def entitlements_summary(ent)
-
feature_keys = %w[
-
interview_prepare_access
-
interview_prepare_refreshes
-
round_prep_access
-
round_prep_generations
-
ai_summaries
-
interviews
-
]
-
-
{
-
subscription_status: ent.subscription_status,
-
purchase_active: ent.purchase_active?,
-
purchase_expires_at: ent.purchase_expires_at&.iso8601,
-
insight_trial_active: ent.insight_trial_active?,
-
insight_trial_expires_at: ent.insight_trial_expires_at&.iso8601,
-
billing_admin_access: user.billing_admin_access?,
-
features: feature_keys.index_with { |k| feature_debug(ent, k) }
-
}
-
end
-
-
# @param ent [Billing::Entitlements]
-
# @param feature_key [String]
-
# @return [Hash]
-
def feature_debug(ent, feature_key)
-
feature = Billing::Feature.find_by(key: feature_key)
-
kind = feature&.kind || "unknown"
-
-
limit = ent.limit(feature_key)
-
remaining = ent.remaining(feature_key)
-
-
{
-
kind: kind,
-
allowed: ent.allowed?(feature_key),
-
limit: limit,
-
remaining: remaining,
-
used_this_period: used_this_period(feature_key, limit: limit, remaining: remaining)
-
}.compact
-
end
-
-
# @param feature_key [String]
-
# @param limit [Integer, nil]
-
# @param remaining [Integer, nil]
-
# @return [Integer, nil]
-
def used_this_period(feature_key, limit:, remaining:)
-
return nil if limit.nil? || remaining.nil?
-
limit - remaining
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Billing
-
# Computes effective entitlements for a user by combining:
-
# - Plan entitlements (from the active subscription, or Free fallback)
-
# - Active entitlement grants (trials/promos/admin overrides)
-
# - Usage counters (for quota remaining)
-
#
-
# Usage:
-
# ent = Billing::Entitlements.for(Current.user)
-
# ent.allowed?(:pattern_detection)
-
# ent.remaining(:ai_summaries)
-
class Entitlements
-
# Legacy plan key aliases for backwards compatibility.
-
#
-
# We previously used simpler keys like "pro" and "sprint". The billing catalog
-
# now uses more explicit keys ("pro_monthly", "sprint_one_time"), but older
-
# subscriptions/grants may still reference the legacy plan records.
-
PLAN_KEY_ALIASES = {
-
"pro" => "pro_monthly",
-
"sprint" => "sprint_one_time"
-
}.freeze
-
-
class << self
-
# @param user [User]
-
# @param at [Time]
-
# @return [Billing::Entitlements]
-
def for(user, at: Time.current)
-
new(user, at: at)
-
end
-
end
-
-
attr_reader :user, :at
-
-
# @param user [User]
-
# @param at [Time]
-
def initialize(user, at: Time.current)
-
@user = user
-
@at = at
-
end
-
-
# Returns the subscription-based plan (does not consider one-time purchases).
-
#
-
# @return [Billing::Plan, nil]
-
def subscription_plan
-
normalized_plan(active_subscription&.plan) || Billing::Plan.find_by(key: "free")
-
end
-
-
# Returns the effective plan, considering both subscriptions and one-time purchase grants.
-
# One-time purchases (like Sprint) take precedence over subscriptions while active.
-
#
-
# @return [Billing::Plan, nil]
-
def effective_plan
-
@effective_plan ||= begin
-
purchase_plan = normalized_plan(active_purchase_grant&.plan)
-
purchase_plan || subscription_plan
-
end
-
end
-
-
# Alias for backward compatibility.
-
#
-
# @return [Billing::Plan, nil]
-
def plan
-
effective_plan
-
end
-
-
# @param feature_key [String, Symbol]
-
# @return [Boolean]
-
def allowed?(feature_key)
-
spec = entitlement_spec(feature_key)
-
spec.fetch("enabled", false) == true
-
end
-
-
# @param feature_key [String, Symbol]
-
# @return [Integer, nil]
-
def limit(feature_key)
-
spec = entitlement_spec(feature_key)
-
spec["limit"]
-
end
-
-
# @param feature_key [String, Symbol]
-
# @return [Integer, nil]
-
def remaining(feature_key)
-
lim = limit(feature_key)
-
return nil if lim.nil?
-
-
used = usage_for(feature_key)
-
[ lim - used, 0 ].max
-
end
-
-
# Returns the active subscription for the user.
-
#
-
# @return [Billing::Subscription, nil]
-
def active_subscription
-
@active_subscription ||= Billing::Subscription.where(user: user).active.order(updated_at: :desc).detect { |s| s.active_at?(at: at) }
-
end
-
-
# Returns the active one-time purchase grant if present (e.g., Sprint).
-
# Purchase grants have source="purchase" and reason starting with "one_time_purchase:".
-
#
-
# @return [Billing::EntitlementGrant, nil]
-
def active_purchase_grant
-
@active_purchase_grant ||= Billing::EntitlementGrant
-
.where(user: user, source: "purchase")
-
.where("reason LIKE ?", "one_time_purchase:%")
-
.active_at(at)
-
.order(expires_at: :desc)
-
.first
-
end
-
-
# @return [Boolean]
-
def purchase_active?
-
active_purchase_grant.present?
-
end
-
-
# Returns when the one-time purchase expires.
-
#
-
# @return [Time, nil]
-
def purchase_expires_at
-
active_purchase_grant&.expires_at
-
end
-
-
# Returns the time remaining for the one-time purchase in words.
-
#
-
# @return [String, nil]
-
def purchase_time_remaining_in_words
-
return nil unless purchase_active?
-
-
seconds = [ (purchase_expires_at - Time.current).to_i, 0 ].max
-
days = seconds / 86400
-
hours = (seconds % 86400) / 3600
-
-
if days > 1
-
"#{days} days"
-
elsif days == 1
-
"1 day"
-
elsif hours > 0
-
"#{hours} hour#{'s' if hours != 1}"
-
else
-
"less than an hour"
-
end
-
end
-
-
# Returns the active insight-triggered trial grant if present.
-
#
-
# @return [Billing::EntitlementGrant, nil]
-
def insight_trial_grant
-
@insight_trial_grant ||= Billing::EntitlementGrant
-
.where(user: user, source: "trial", reason: "insight_triggered")
-
.active_at(at)
-
.first
-
end
-
-
# @return [Boolean]
-
def insight_trial_active?
-
insight_trial_grant.present?
-
end
-
-
# @return [Time, nil]
-
def insight_trial_expires_at
-
insight_trial_grant&.expires_at
-
end
-
-
# Returns the time remaining in the insight trial in seconds.
-
#
-
# @return [Integer, nil]
-
def insight_trial_time_remaining
-
return nil unless insight_trial_active?
-
[ (insight_trial_expires_at - Time.current).to_i, 0 ].max
-
end
-
-
# Returns a human-readable time remaining string.
-
#
-
# @return [String, nil]
-
def insight_trial_time_remaining_in_words
-
seconds = insight_trial_time_remaining
-
return nil if seconds.nil?
-
-
hours = seconds / 3600
-
minutes = (seconds % 3600) / 60
-
-
if hours > 0
-
"#{hours} hour#{'s' if hours != 1}"
-
elsif minutes > 0
-
"#{minutes} minute#{'s' if minutes != 1}"
-
else
-
"less than a minute"
-
end
-
end
-
-
# Returns the overall subscription status.
-
#
-
# @return [Symbol] :trial, :free, :active, :trialing, :cancelled, :past_due, :expired, :inactive
-
def subscription_status
-
return :trial if insight_trial_active? && active_subscription.nil?
-
return :free if active_subscription.nil?
-
active_subscription.status.to_sym
-
end
-
-
# Returns the next billing/renewal date.
-
#
-
# @return [Time, nil]
-
def renewal_date
-
active_subscription&.current_period_ends_at
-
end
-
-
# Returns whether the subscription is set to cancel at period end.
-
#
-
# @return [Boolean]
-
def cancel_at_period_end?
-
active_subscription&.cancel_at_period_end || false
-
end
-
-
# Returns usage data for all quota features.
-
#
-
# @return [Hash] { feature_key => { used: X, limit: Y, remaining: Z, name: String } }
-
def quota_usage
-
quota_features = Billing::Feature.where(kind: "quota")
-
-
quota_features.each_with_object({}) do |feature, hash|
-
lim = limit(feature.key)
-
used = usage_for(feature.key)
-
hash[feature.key] = {
-
name: feature.name,
-
used: used,
-
limit: lim,
-
remaining: lim.nil? ? nil : [ lim - used, 0 ].max,
-
unlimited: lim.nil?
-
}
-
end
-
end
-
-
private
-
-
def entitlement_spec(feature_key)
-
key = feature_key.to_s
-
-
merged = plan_entitlements_hash
-
grants = active_grants
-
-
# Grants override plan entitlements
-
grants.each do |grant|
-
grant.entitlements.each do |k, v|
-
merged[k.to_s] = (merged[k.to_s] || {}).merge(v || {})
-
end
-
end
-
-
spec = merged[key] || {}
-
-
# Admin/Developer access is intended to grant full, unrestricted access.
-
# Historically we expanded all features into the grant's entitlements JSON.
-
# If a feature is added later, older grants may not include it; treat an
-
# active admin grant as a wildcard override.
-
if admin_access_active?(grants)
-
feature = Billing::Feature.find_by(key: key)
-
if feature&.kind == "quota"
-
spec = spec.merge("enabled" => true, "limit" => nil)
-
else
-
spec = spec.merge("enabled" => true)
-
end
-
end
-
-
spec
-
end
-
-
# @param plan [Billing::Plan, nil]
-
# @return [Billing::Plan, nil]
-
def normalized_plan(plan)
-
return nil if plan.nil?
-
-
canonical_key = PLAN_KEY_ALIASES[plan.key]
-
return plan if canonical_key.blank?
-
-
Billing::Plan.find_by(key: canonical_key) || plan
-
end
-
-
# @param grants [Array<Billing::EntitlementGrant>]
-
# @return [Boolean]
-
def admin_access_active?(grants)
-
grants.any? { |g| g.source == "admin" && g.reason == "admin_developer" }
-
end
-
-
def plan_entitlements_hash
-
p = plan
-
return {} if p.nil?
-
-
p.plan_entitlements.includes(:feature).each_with_object({}) do |ent, h|
-
next if ent.feature.nil?
-
-
h[ent.feature.key] = {
-
"enabled" => ent.enabled == true,
-
"limit" => ent.limit
-
}.compact
-
end
-
end
-
-
def active_grants
-
Billing::EntitlementGrant.active_at(at).where(user: user).order(created_at: :asc).to_a
-
end
-
-
def usage_for(feature_key)
-
period = usage_period
-
counter = Billing::UsageCounter.find_by(
-
user: user,
-
feature_key: feature_key.to_s,
-
period_starts_at: period[:starts_at]
-
)
-
counter&.used.to_i
-
end
-
-
# For v1 we use a simple calendar-month usage window.
-
# (We can evolve this later to support per-plan windows or Sprint-style 30-day windows.)
-
def usage_period
-
starts_at = at.beginning_of_month
-
ends_at = (starts_at + 1.month)
-
{ starts_at: starts_at, ends_at: ends_at }
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Billing
-
# Handles plan switching logic, ensuring only one active plan at a time.
-
#
-
# Subscription → Sprint: Cancels subscription at period end, Sprint activates immediately.
-
# Sprint → Subscription: Deactivates Sprint grant, subscription activates immediately.
-
#
-
# LemonSqueezy handles proration automatically for subscription changes.
-
class PlanSwitcher < ApplicationService
-
attr_reader :user
-
-
# @param user [User]
-
def initialize(user)
-
@user = user
-
end
-
-
# Prepares for switching to a new plan by cancelling conflicting plans.
-
#
-
# @param new_plan [Billing::Plan]
-
# @return [Hash] { cancelled_subscription: Boolean, deactivated_grant: Boolean }
-
def prepare_switch(new_plan)
-
result = { cancelled_subscription: false, deactivated_grant: false }
-
-
if new_plan.one_time?
-
# Switching to one-time plan (Sprint) - cancel any active subscription
-
result[:cancelled_subscription] = cancel_active_subscription
-
else
-
# Switching to subscription plan - deactivate any active one-time purchase grants
-
result[:deactivated_grant] = deactivate_purchase_grants
-
end
-
-
result
-
end
-
-
# Cancels the user's active subscription at period end.
-
# The user keeps access until the billing period ends, then it expires.
-
#
-
# @return [Boolean] true if a subscription was cancelled
-
def cancel_active_subscription
-
subscription = active_subscription
-
return false if subscription.nil?
-
-
# Skip if already cancelled
-
return false if subscription.cancel_at_period_end
-
-
provider = Billing::Providers::LemonSqueezy.new
-
provider.cancel_subscription(subscription: subscription)
-
-
log_info(
-
"cancelled subscription for one-time purchase " \
-
"user_id=#{user.id} subscription_id=#{subscription.id}"
-
)
-
-
true
-
rescue => e
-
notify_error(
-
e,
-
context: "billing",
-
severity: "error",
-
user: user,
-
tags: { operation: "plan_switch", action: "cancel_subscription" },
-
subscription_id: subscription&.id
-
)
-
# Don't block the checkout - subscription will remain active alongside Sprint
-
false
-
end
-
-
# Deactivates active one-time purchase grants (e.g., Sprint).
-
# This allows subscription features to take over.
-
#
-
# @return [Boolean] true if any grants were deactivated
-
def deactivate_purchase_grants
-
grants = active_purchase_grants
-
return false if grants.empty?
-
-
grants.each do |grant|
-
# Set expires_at to now to deactivate
-
grant.update!(
-
expires_at: Time.current,
-
metadata: grant.metadata.merge(
-
"deactivated_reason" => "subscription_switch",
-
"deactivated_at" => Time.current.iso8601,
-
"original_expires_at" => grant.expires_at_was&.iso8601
-
)
-
)
-
-
log_info(
-
"deactivated purchase grant for subscription " \
-
"user_id=#{user.id} grant_id=#{grant.id}"
-
)
-
end
-
-
true
-
rescue => e
-
notify_error(
-
e,
-
context: "billing",
-
severity: "error",
-
user: user,
-
tags: { operation: "plan_switch", action: "deactivate_grants" },
-
grant_ids: grants&.map(&:id)
-
)
-
false
-
end
-
-
# Returns the current plan type the user is on.
-
#
-
# @return [Symbol] :subscription, :one_time, :free
-
def current_plan_type
-
return :one_time if active_purchase_grants.any?
-
return :subscription if active_subscription.present?
-
-
:free
-
end
-
-
# Returns whether switching to the given plan requires cancellation.
-
#
-
# @param new_plan [Billing::Plan]
-
# @return [Boolean]
-
def requires_cancellation?(new_plan)
-
return false if current_plan_type == :free
-
-
if new_plan.one_time?
-
# Switching to Sprint requires cancelling subscription
-
current_plan_type == :subscription
-
else
-
# Switching to subscription requires deactivating Sprint
-
current_plan_type == :one_time
-
end
-
end
-
-
private
-
-
def active_subscription
-
@active_subscription ||= user.billing_subscriptions
-
.where(provider: "lemonsqueezy")
-
.where(status: %w[active trialing])
-
.where(cancel_at_period_end: [ false, nil ])
-
.order(updated_at: :desc)
-
.first
-
end
-
-
def active_purchase_grants
-
@active_purchase_grants ||= Billing::EntitlementGrant
-
.where(user: user, source: "purchase")
-
.where("reason LIKE ?", "one_time_purchase:%")
-
.where("starts_at <= ? AND expires_at > ?", Time.current, Time.current)
-
.to_a
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Billing
-
module Providers
-
# LemonSqueezy payment provider implementation.
-
#
-
# Uses the LemonSqueezy API to create hosted checkout URLs and relies on webhooks
-
# to sync subscription state back into the app.
-
class LemonSqueezy < ApplicationService
-
include HTTParty
-
-
base_uri "https://api.lemonsqueezy.com/v1"
-
-
# @param api_key [String, nil]
-
def initialize(api_key: nil)
-
@api_key = api_key.presence || Rails.application.credentials.dig(:lemonsqueezy, :api_key) || ENV["LEMONSQUEEZY_API_KEY"]
-
end
-
-
# Creates a hosted checkout URL for the given plan.
-
#
-
# Requires a Billing::ProviderMapping for provider=lemonsqueezy with:
-
# - external_variant_id
-
# - metadata["store_id"]
-
#
-
# @param user [User]
-
# @param plan [Billing::Plan]
-
# @return [String] hosted checkout URL
-
def create_checkout(user:, plan:)
-
mapping = Billing::ProviderMapping.find_by!(provider: "lemonsqueezy", plan: plan)
-
store_id = Rails.application.credentials.dig(:lemonsqueezy, :store_id) || ENV["LEMONSQUEEZY_STORE_ID"] || mapping.metadata["store_id"].presence
-
variant_id = mapping.external_variant_id.presence
-
-
raise "Missing LemonSqueezy store_id in provider mapping metadata" if store_id.blank?
-
raise "Missing LemonSqueezy external_variant_id in provider mapping" if variant_id.blank?
-
raise "Missing LemonSqueezy API key" if api_key.blank?
-
-
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
-
-
body = {
-
data: {
-
type: "checkouts",
-
attributes: {
-
checkout_data: {
-
email: user.email_address,
-
custom: {
-
user_id: user.id.to_s
-
}
-
}
-
},
-
relationships: {
-
store: { data: { type: "stores", id: store_id.to_s } },
-
variant: { data: { type: "variants", id: variant_id.to_s } }
-
}
-
}
-
}
-
-
response = self.class.post(
-
"/checkouts",
-
headers: request_headers,
-
body: body.to_json
-
)
-
-
parsed = response.parsed_response.is_a?(Hash) ? response.parsed_response : {}
-
url = parsed.dig("data", "attributes", "url") ||
-
parsed.dig("data", "attributes", "checkout_url") ||
-
parsed.dig("data", "attributes", "checkout_url_string")
-
-
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
-
Rails.logger.info("[billing] lemonsqueezy checkout create status=#{response.code} duration_ms=#{duration_ms} plan_key=#{plan.key} user_id=#{user.id}")
-
-
raise "LemonSqueezy checkout creation failed (status=#{response.code})" unless response.code.to_i.between?(200, 299)
-
raise "LemonSqueezy checkout URL missing from response" if url.blank?
-
-
url
-
rescue => e
-
notify_error(
-
e,
-
context: "payment",
-
severity: "error",
-
user: user,
-
tags: { provider: "lemonsqueezy", operation: "create_checkout" },
-
plan_key: plan&.key
-
)
-
raise
-
end
-
-
# Retrieves the LemonSqueezy customer portal URL for a user.
-
#
-
# @param customer [Billing::Customer] the customer record with external_customer_id
-
# @return [String] the customer portal URL
-
def customer_portal_url(customer:)
-
raise "Missing LemonSqueezy API key" if api_key.blank?
-
raise "Customer external_customer_id is required" if customer&.external_customer_id.blank?
-
-
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
-
-
response = self.class.get(
-
"/customers/#{customer.external_customer_id}/portal",
-
headers: request_headers
-
)
-
-
parsed = response.parsed_response.is_a?(Hash) ? response.parsed_response : {}
-
url = parsed.dig("data", "attributes", "urls", "customer_portal") ||
-
parsed.dig("data", "attributes", "url")
-
-
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
-
Rails.logger.info("[billing] lemonsqueezy customer_portal status=#{response.code} duration_ms=#{duration_ms} customer_id=#{customer.id}")
-
-
raise "LemonSqueezy customer portal request failed (status=#{response.code})" unless response.code.to_i.between?(200, 299)
-
raise "LemonSqueezy customer portal URL missing from response" if url.blank?
-
-
url
-
rescue => e
-
notify_error(
-
e,
-
context: "payment",
-
severity: "error",
-
tags: { provider: "lemonsqueezy", operation: "customer_portal" },
-
customer_id: customer&.id
-
)
-
raise
-
end
-
-
# Cancels a subscription at period end.
-
#
-
# @param subscription [Billing::Subscription]
-
# @return [Boolean] true if successful
-
def cancel_subscription(subscription:)
-
raise "Missing LemonSqueezy API key" if api_key.blank?
-
raise "Subscription external_subscription_id is required" if subscription&.external_subscription_id.blank?
-
-
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
-
-
body = {
-
data: {
-
type: "subscriptions",
-
id: subscription.external_subscription_id,
-
attributes: {
-
cancelled: true
-
}
-
}
-
}
-
-
response = self.class.patch(
-
"/subscriptions/#{subscription.external_subscription_id}",
-
headers: request_headers,
-
body: body.to_json
-
)
-
-
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
-
Rails.logger.info("[billing] lemonsqueezy cancel_subscription status=#{response.code} duration_ms=#{duration_ms} subscription_id=#{subscription.id}")
-
-
unless response.code.to_i.between?(200, 299)
-
raise "LemonSqueezy cancel subscription failed (status=#{response.code})"
-
end
-
-
# Update local record optimistically (webhook will confirm)
-
subscription.update!(cancel_at_period_end: true)
-
true
-
rescue => e
-
notify_error(
-
e,
-
context: "payment",
-
severity: "error",
-
tags: { provider: "lemonsqueezy", operation: "cancel_subscription" },
-
subscription_id: subscription&.id
-
)
-
raise
-
end
-
-
# Resumes a cancelled subscription (removes cancellation).
-
#
-
# @param subscription [Billing::Subscription]
-
# @return [Boolean] true if successful
-
def resume_subscription(subscription:)
-
raise "Missing LemonSqueezy API key" if api_key.blank?
-
raise "Subscription external_subscription_id is required" if subscription&.external_subscription_id.blank?
-
-
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
-
-
body = {
-
data: {
-
type: "subscriptions",
-
id: subscription.external_subscription_id,
-
attributes: {
-
cancelled: false
-
}
-
}
-
}
-
-
response = self.class.patch(
-
"/subscriptions/#{subscription.external_subscription_id}",
-
headers: request_headers,
-
body: body.to_json
-
)
-
-
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
-
Rails.logger.info("[billing] lemonsqueezy resume_subscription status=#{response.code} duration_ms=#{duration_ms} subscription_id=#{subscription.id}")
-
-
unless response.code.to_i.between?(200, 299)
-
raise "LemonSqueezy resume subscription failed (status=#{response.code})"
-
end
-
-
# Update local record optimistically (webhook will confirm)
-
subscription.update!(cancel_at_period_end: false)
-
true
-
rescue => e
-
notify_error(
-
e,
-
context: "payment",
-
severity: "error",
-
tags: { provider: "lemonsqueezy", operation: "resume_subscription" },
-
subscription_id: subscription&.id
-
)
-
raise
-
end
-
-
private
-
-
attr_reader :api_key
-
-
def request_headers
-
{
-
"Authorization" => "Bearer #{api_key}",
-
"Accept" => "application/vnd.api+json",
-
"Content-Type" => "application/vnd.api+json"
-
}
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Billing
-
# Seeds the billing catalog (plans, features, entitlements) in an idempotent way.
-
#
-
# This is safe to run multiple times and in any environment.
-
#
-
# Usage:
-
# Billing::SeedCatalogService.new.run!
-
class SeedCatalogService
-
# @return [void]
-
def run!
-
Billing::Plan.transaction do
-
upsert_features!
-
upsert_plans!
-
upsert_plan_entitlements!
-
end
-
-
Billing::Catalog.purge_cache!
-
end
-
-
private
-
-
def upsert_features!
-
features.each do |attrs|
-
feature = Billing::Feature.find_or_initialize_by(key: attrs.fetch(:key))
-
feature.assign_attributes(attrs.except(:key))
-
feature.save!
-
end
-
end
-
-
def upsert_plans!
-
plans.each do |attrs|
-
plan = Billing::Plan.find_or_initialize_by(key: attrs.fetch(:key))
-
plan.assign_attributes(attrs.except(:key))
-
plan.save!
-
end
-
end
-
-
def upsert_plan_entitlements!
-
plans_by_key = Billing::Plan.where(key: plans.map { |p| p[:key] }).index_by(&:key)
-
features_by_key = Billing::Feature.where(key: features.map { |f| f[:key] }).index_by(&:key)
-
-
plan_entitlements.each do |row|
-
plan = plans_by_key.fetch(row.fetch(:plan_key))
-
feature = features_by_key.fetch(row.fetch(:feature_key))
-
-
ent = Billing::PlanEntitlement.find_or_initialize_by(plan: plan, feature: feature)
-
ent.enabled = row.fetch(:enabled, true)
-
ent.limit = row[:limit]
-
ent.save!
-
end
-
end
-
-
def features
-
[
-
{ key: "cv_parsing_basic", name: "CV parsing (basic)", kind: "boolean", description: "Basic CV parsing", unit: nil },
-
{ key: "cv_parsing_full", name: "CV parsing (full)", kind: "boolean", description: "Full CV intelligence extraction", unit: nil },
-
{ key: "skill_domain_extraction_limited", name: "Career signal extraction (limited)", kind: "boolean", description: "Limited skills/domains extraction", unit: nil },
-
{ key: "skill_domain_extraction_full", name: "Career signal extraction (full)", kind: "boolean", description: "Full skills/domains/seniority extraction", unit: nil },
-
-
{ key: "interviews", name: "Interview tracking quota", kind: "quota", description: "Number of interview rounds/applications allowed", unit: "interviews" },
-
{ key: "ai_summaries", name: "AI summaries quota", kind: "quota", description: "AI summaries and synthesis quota", unit: "summaries" },
-
-
{ key: "pattern_detection", name: "Pattern detection", kind: "boolean", description: "Themes over time / improvement patterns", unit: nil },
-
{ key: "cv_feedback_comparison", name: "CV ↔ interview comparison", kind: "boolean", description: "Cross-analysis between CV and interviews/feedback", unit: nil },
-
{ key: "cv_feedback_comparison_enhanced", name: "CV ↔ interview comparison (enhanced)", kind: "boolean", description: "Deeper cross-analysis for Sprint", unit: nil },
-
-
{ key: "assistant_access", name: "Assistant access", kind: "boolean", description: "Context-aware assistant access", unit: nil },
-
{ key: "assistant_priority", name: "Assistant priority", kind: "boolean", description: "Priority assistant depth/processing", unit: nil },
-
{ key: "background_processing_priority", name: "Priority background processing", kind: "boolean", description: "Priority background processing", unit: nil },
-
-
{ key: "insight_export", name: "Insight export", kind: "boolean", description: "Export insights", unit: nil },
-
-
# Interview preparation (application-specific coaching)
-
{ key: "interview_prepare_access", name: "Interview prepare access", kind: "boolean", description: "Access to the Prepare tab coaching experience", unit: nil },
-
{ key: "interview_prepare_refreshes", name: "Interview prepare refresh quota", kind: "quota", description: "Prep pack refresh quota", unit: "refreshes" },
-
-
# Round-specific interview prep (per-round coaching)
-
{ key: "round_prep_access", name: "Round prep access", kind: "boolean", description: "Access to round-specific interview prep", unit: nil },
-
{ key: "round_prep_generations", name: "Round prep generation quota", kind: "quota", description: "Round-specific prep generation quota", unit: "generations" },
-
-
# Internal feature used by the 72-hour trial grant.
-
{ key: "pro_trial_access", name: "Pro trial access", kind: "boolean", description: "Unlocked during earned Pro trial window", unit: nil }
-
]
-
end
-
-
def plans
-
[
-
{
-
key: "free",
-
name: "Free — Reflect",
-
description: "Trust & habit-building tier",
-
plan_type: "free",
-
interval: nil,
-
amount_cents: 0,
-
currency: "eur",
-
highlighted: false,
-
published: true,
-
sort_order: 0,
-
metadata: {
-
pricing_features: [
-
"Upload & parse CV (basic)",
-
"Manual interview tracking (limited)",
-
"Feedback journal (never gated)",
-
"Basic AI summaries (low quota)",
-
"Simple strengths & improvement tags"
-
]
-
}
-
},
-
{
-
key: "pro_monthly",
-
name: "Pro — Grow",
-
description: "Understand your professional profile — and improve it through every interview.",
-
plan_type: "recurring",
-
interval: "month",
-
amount_cents: 1200,
-
currency: "eur",
-
highlighted: true,
-
published: true,
-
sort_order: 10,
-
metadata: {
-
pricing_features: [
-
"Everything in Free",
-
"Unlimited interviews & feedback entries",
-
"Full career signal extraction",
-
"Experience-backed insights over time",
-
"Assistant access (fair use)",
-
"Interview preparation access"
-
]
-
}
-
},
-
{
-
key: "sprint_one_time",
-
name: "Sprint — Interview Focus",
-
description: "A focused month of clarity while you’re actively interviewing.",
-
plan_type: "one_time",
-
interval: nil,
-
amount_cents: 2500,
-
currency: "eur",
-
highlighted: false,
-
published: true,
-
sort_order: 20,
-
metadata: {
-
pricing_features: [
-
"Everything in Pro",
-
"Higher AI limits",
-
"Deeper CV ↔ interview cross-analysis",
-
"Faster insight refresh",
-
"Priority background processing",
-
"Interview preparation access"
-
]
-
}
-
},
-
{
-
key: "admin_developer",
-
name: "Admin/Developer",
-
description: "Internal plan for staff/admin access (not customer-facing).",
-
plan_type: "free",
-
interval: nil,
-
amount_cents: 0,
-
currency: "eur",
-
highlighted: false,
-
published: false,
-
sort_order: 999,
-
metadata: {}
-
}
-
]
-
end
-
-
def plan_entitlements
-
[
-
# Free
-
{ plan_key: "free", feature_key: "cv_parsing_basic", enabled: true },
-
{ plan_key: "free", feature_key: "cv_parsing_full", enabled: false },
-
{ plan_key: "free", feature_key: "skill_domain_extraction_limited", enabled: true },
-
{ plan_key: "free", feature_key: "skill_domain_extraction_full", enabled: false },
-
{ plan_key: "free", feature_key: "interviews", enabled: true, limit: 5 },
-
{ plan_key: "free", feature_key: "ai_summaries", enabled: true, limit: 5 },
-
{ plan_key: "free", feature_key: "pattern_detection", enabled: false },
-
{ plan_key: "free", feature_key: "cv_feedback_comparison", enabled: false },
-
{ plan_key: "free", feature_key: "cv_feedback_comparison_enhanced", enabled: false },
-
{ plan_key: "free", feature_key: "assistant_access", enabled: false },
-
{ plan_key: "free", feature_key: "assistant_priority", enabled: false },
-
{ plan_key: "free", feature_key: "background_processing_priority", enabled: false },
-
{ plan_key: "free", feature_key: "insight_export", enabled: false },
-
{ plan_key: "free", feature_key: "interview_prepare_access", enabled: false },
-
{ plan_key: "free", feature_key: "interview_prepare_refreshes", enabled: true, limit: 0 },
-
{ plan_key: "free", feature_key: "round_prep_access", enabled: false },
-
{ plan_key: "free", feature_key: "round_prep_generations", enabled: true, limit: 0 },
-
-
# Pro
-
{ plan_key: "pro_monthly", feature_key: "cv_parsing_basic", enabled: true },
-
{ plan_key: "pro_monthly", feature_key: "cv_parsing_full", enabled: true },
-
{ plan_key: "pro_monthly", feature_key: "skill_domain_extraction_limited", enabled: true },
-
{ plan_key: "pro_monthly", feature_key: "skill_domain_extraction_full", enabled: true },
-
{ plan_key: "pro_monthly", feature_key: "interviews", enabled: true, limit: nil },
-
{ plan_key: "pro_monthly", feature_key: "ai_summaries", enabled: true, limit: 50 },
-
{ plan_key: "pro_monthly", feature_key: "pattern_detection", enabled: true },
-
{ plan_key: "pro_monthly", feature_key: "cv_feedback_comparison", enabled: true },
-
{ plan_key: "pro_monthly", feature_key: "cv_feedback_comparison_enhanced", enabled: false },
-
{ plan_key: "pro_monthly", feature_key: "assistant_access", enabled: true },
-
{ plan_key: "pro_monthly", feature_key: "assistant_priority", enabled: false },
-
{ plan_key: "pro_monthly", feature_key: "background_processing_priority", enabled: false },
-
{ plan_key: "pro_monthly", feature_key: "insight_export", enabled: true },
-
{ plan_key: "pro_monthly", feature_key: "interview_prepare_access", enabled: true },
-
{ plan_key: "pro_monthly", feature_key: "interview_prepare_refreshes", enabled: true, limit: 10 },
-
{ plan_key: "pro_monthly", feature_key: "round_prep_access", enabled: true },
-
{ plan_key: "pro_monthly", feature_key: "round_prep_generations", enabled: true, limit: 20 },
-
-
# Sprint
-
{ plan_key: "sprint_one_time", feature_key: "cv_parsing_basic", enabled: true },
-
{ plan_key: "sprint_one_time", feature_key: "cv_parsing_full", enabled: true },
-
{ plan_key: "sprint_one_time", feature_key: "skill_domain_extraction_limited", enabled: true },
-
{ plan_key: "sprint_one_time", feature_key: "skill_domain_extraction_full", enabled: true },
-
{ plan_key: "sprint_one_time", feature_key: "interviews", enabled: true, limit: nil },
-
{ plan_key: "sprint_one_time", feature_key: "ai_summaries", enabled: true, limit: 200 },
-
{ plan_key: "sprint_one_time", feature_key: "pattern_detection", enabled: true },
-
{ plan_key: "sprint_one_time", feature_key: "cv_feedback_comparison", enabled: true },
-
{ plan_key: "sprint_one_time", feature_key: "cv_feedback_comparison_enhanced", enabled: true },
-
{ plan_key: "sprint_one_time", feature_key: "assistant_access", enabled: true },
-
{ plan_key: "sprint_one_time", feature_key: "assistant_priority", enabled: true },
-
{ plan_key: "sprint_one_time", feature_key: "background_processing_priority", enabled: true },
-
{ plan_key: "sprint_one_time", feature_key: "insight_export", enabled: true },
-
{ plan_key: "sprint_one_time", feature_key: "interview_prepare_access", enabled: true },
-
{ plan_key: "sprint_one_time", feature_key: "interview_prepare_refreshes", enabled: true, limit: 50 },
-
{ plan_key: "sprint_one_time", feature_key: "round_prep_access", enabled: true },
-
{ plan_key: "sprint_one_time", feature_key: "round_prep_generations", enabled: true, limit: 100 }
-
]
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Billing
-
# Service for unlocking an insight-triggered Pro trial for a user.
-
#
-
# This is provider-agnostic and implemented via Billing::EntitlementGrant
-
# so it works with LemonSqueezy today and other providers later.
-
#
-
# Eligibility:
-
# - Once per user lifetime
-
# - Only if user is not already on an active paid subscription
-
#
-
# @example
-
# result = Billing::TrialUnlockService.new(user: Current.user, trigger: :first_feedback_after_cv).run
-
# result[:unlocked] # => true/false
-
class TrialUnlockService < ApplicationService
-
TRIAL_DURATION = 72.hours
-
-
REASON = "insight_triggered"
-
SOURCE = "trial"
-
-
# Feature keys granted during the trial (cost-bounded).
-
# These keys are used by Billing::Entitlements and can be backed by catalog
-
# features later (developer portal managed).
-
TRIAL_ENTITLEMENTS = {
-
"cv_full_analysis" => { "enabled" => true },
-
"feedback_synthesis_advanced" => { "enabled" => true },
-
"pattern_detection" => { "enabled" => true },
-
"assistant_access" => { "enabled" => true },
-
"interview_prepare_access" => { "enabled" => true },
-
"round_prep_access" => { "enabled" => true },
-
# Quotas (absolute caps) to control AI spend during trial
-
"ai_summaries" => { "enabled" => true, "limit" => 25 },
-
"interview_prepare_refreshes" => { "enabled" => true, "limit" => 10 },
-
"round_prep_generations" => { "enabled" => true, "limit" => 10 },
-
"assistant_messages" => { "enabled" => true, "limit" => 50 }
-
}.freeze
-
-
# @param user [User]
-
# @param trigger [Symbol, String] the trigger event name
-
# @param metadata [Hash] optional metadata (e.g., counts, ids)
-
def initialize(user:, trigger:, metadata: {})
-
@user = user
-
@trigger = trigger.to_s
-
@metadata = metadata || {}
-
end
-
-
# Attempts to unlock the trial.
-
#
-
# @return [Hash] result hash: { unlocked: Boolean, grant: Billing::EntitlementGrant|nil, expires_at: Time|nil }
-
def run
-
return { unlocked: false, grant: nil, expires_at: nil } if user.nil?
-
return { unlocked: false, grant: nil, expires_at: nil } if ineligible_due_to_subscription?
-
-
Billing::EntitlementGrant.transaction do
-
return { unlocked: false, grant: nil, expires_at: nil } if already_unlocked?
-
-
now = Time.current
-
grant = Billing::EntitlementGrant.create!(
-
user: user,
-
source: SOURCE,
-
reason: REASON,
-
starts_at: now,
-
expires_at: now + TRIAL_DURATION,
-
entitlements: TRIAL_ENTITLEMENTS,
-
metadata: metadata.merge(trigger: trigger)
-
)
-
-
{ unlocked: true, grant: grant, expires_at: grant.expires_at }
-
end
-
rescue => e
-
notify_error(
-
e,
-
context: "payment",
-
severity: "error",
-
user: user,
-
trigger: trigger,
-
metadata: metadata
-
)
-
{ unlocked: false, grant: nil, expires_at: nil }
-
end
-
-
private
-
-
attr_reader :user, :trigger, :metadata
-
-
def already_unlocked?
-
Billing::EntitlementGrant.where(user: user, source: SOURCE, reason: REASON).exists?
-
end
-
-
def ineligible_due_to_subscription?
-
Billing::Subscription.where(user: user).active.any? { |s| s.active_at?(at: Time.current) }
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Billing
-
module Webhooks
-
# Processes LemonSqueezy webhook events and syncs subscription state.
-
#
-
# Payload format can vary by event type and LemonSqueezy version; we parse defensively.
-
class LemonSqueezyProcessor < ApplicationService
-
# @param webhook_event [Billing::WebhookEvent]
-
def initialize(webhook_event)
-
@webhook_event = webhook_event
-
@payload = webhook_event.payload || {}
-
end
-
-
# @return [void]
-
def run
-
event_type = extract_event_type
-
webhook_event.update!(event_type: event_type) if webhook_event.event_type.blank? && event_type.present?
-
-
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
-
handled = handle_subscription_event(event_type)
-
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
-
log_info("webhook processed event_type=#{event_type} handled=#{handled} duration_ms=#{duration_ms} id=#{webhook_event.id}")
-
-
webhook_event.update!(
-
status: handled ? "processed" : "ignored",
-
processed_at: Time.current
-
)
-
rescue => e
-
notify_error(
-
e,
-
context: "payment",
-
severity: "error",
-
tags: { provider: "lemonsqueezy", operation: "webhook_process", event_type: webhook_event.event_type },
-
webhook_event_id: webhook_event.id
-
)
-
webhook_event.update!(status: "failed", processed_at: Time.current, error_message: "#{e.class}: #{e.message}")
-
end
-
-
private
-
-
attr_reader :webhook_event, :payload
-
-
def extract_event_type
-
payload.dig("meta", "event_name") ||
-
payload["event_name"] ||
-
payload["type"] ||
-
payload.dig("meta", "event") ||
-
payload.dig("meta", "name")
-
end
-
-
def handle_subscription_event(event_type)
-
return false if event_type.blank?
-
-
normalized = event_type.to_s.downcase
-
return handle_order_event if normalized.include?("order")
-
return handle_subscription_invoice_event if subscription_invoice_event?(normalized)
-
return false unless normalized.include?("subscription")
-
-
handle_subscription_payload
-
end
-
-
# @return [Boolean]
-
def handle_subscription_payload
-
subscription_data = payload["data"] || payload.dig("data", "data") || {}
-
attributes = subscription_data["attributes"] || payload.dig("data", "attributes") || {}
-
subscription_id = subscription_data["id"] || payload.dig("data", "id") || attributes["subscription_id"]
-
return false if subscription_id.blank?
-
-
user = resolve_user(attributes)
-
return false if user.nil?
-
-
plan = resolve_plan(attributes)
-
urls = extract_subscription_urls(attributes)
-
-
subscription = Billing::Subscription.find_or_initialize_by(
-
provider: "lemonsqueezy",
-
external_subscription_id: subscription_id.to_s,
-
user: user
-
)
-
-
subscription.plan = plan if plan.present?
-
subscription.status = normalize_status(attributes["status"] || attributes["state"] || subscription.status)
-
subscription.trial_ends_at = parse_time(attributes["trial_ends_at"] || attributes["trial_end_date"] || attributes["trial_end"])
-
subscription.current_period_starts_at = parse_time(attributes["current_period_start"] || attributes["current_period_starts_at"] || attributes["renews_at"])
-
subscription.current_period_ends_at = parse_time(attributes["current_period_end"] || attributes["current_period_ends_at"] || attributes["ends_at"] || attributes["renews_at"])
-
subscription.cancel_at_period_end = truthy?(attributes["cancel_at_period_end"] || attributes["cancel_at_end"] || false)
-
subscription.cancelled_at = parse_time(attributes["cancelled_at"])
-
-
# Store payment method details
-
subscription.card_brand = attributes["card_brand"] if attributes["card_brand"].present?
-
subscription.card_last_four = attributes["card_last_four"] if attributes["card_last_four"].present?
-
-
metadata = subscription.metadata || {}
-
metadata["raw"] = attributes
-
metadata["order_id"] = attributes["order_id"] if attributes["order_id"].present?
-
metadata["customer_id"] = attributes["customer_id"] if attributes["customer_id"].present?
-
subscription.metadata = metadata
-
-
apply_subscription_urls(subscription, urls)
-
-
subscription.save!
-
-
customer = sync_customer_mapping(user: user, attributes: attributes, urls: urls)
-
link_order_to_subscription(subscription, customer, attributes["order_id"])
-
-
# Deactivate any active one-time purchase grants when subscription activates
-
# This ensures only one plan is active at a time
-
if subscription.status == "active"
-
deactivate_purchase_grants_for_subscription(user, subscription)
-
end
-
-
true
-
end
-
-
# @return [Boolean]
-
def handle_subscription_invoice_event
-
invoice_data = payload["data"] || payload.dig("data", "data") || {}
-
attributes = invoice_data["attributes"] || payload.dig("data", "attributes") || {}
-
subscription_id = attributes["subscription_id"] || invoice_data["subscription_id"]
-
return false if subscription_id.blank?
-
-
subscription = Billing::Subscription.find_by(provider: "lemonsqueezy", external_subscription_id: subscription_id.to_s)
-
return false if subscription.nil?
-
-
invoice_url = attributes.dig("urls", "invoice_url")
-
return false if invoice_url.blank?
-
-
metadata = subscription.metadata || {}
-
metadata["latest_invoice_id"] = invoice_data["id"] || attributes["id"]
-
metadata["latest_invoice_status"] = attributes["status"]
-
metadata["latest_invoice_total"] = attributes["total"]
-
metadata["latest_invoice_currency"] = attributes["currency"]
-
subscription.latest_invoice_url = invoice_url
-
subscription.update!(metadata: metadata)
-
-
true
-
end
-
-
# @return [Boolean]
-
def handle_order_event
-
order_data = payload["data"] || payload.dig("data", "data") || {}
-
attributes = order_data["attributes"] || payload.dig("data", "attributes") || {}
-
user = resolve_user(attributes)
-
return false if user.nil?
-
-
order_id = order_data["id"] || attributes["id"]
-
return false if order_id.blank?
-
-
receipt_url = attributes.dig("urls", "receipt")
-
plan = resolve_plan(attributes)
-
subscription = resolve_subscription_for_order(user, attributes)
-
customer = sync_customer_mapping(user: user, attributes: attributes)
-
-
order = Billing::Order.find_or_initialize_by(provider: "lemonsqueezy", external_order_id: order_id.to_s)
-
order.user = user
-
order.customer = customer
-
order.subscription = subscription
-
order.status = attributes["status"]
-
order.total_cents = attributes["total"]
-
order.currency = attributes["currency"]&.downcase
-
order.order_number = attributes["order_number"]&.to_s
-
order.identifier = attributes["identifier"]&.to_s
-
order.receipt_url = receipt_url
-
order.metadata = (order.metadata || {}).merge(raw: attributes)
-
order.save!
-
-
if receipt_url.present?
-
customer.latest_receipt_url = receipt_url if customer.present?
-
customer.save! if customer&.changed?
-
-
update_subscription_receipt(subscription, order) if subscription.present?
-
end
-
-
# For one-time purchases, grant entitlements and cancel any active subscriptions
-
if plan&.one_time?
-
grant_one_time_entitlements(user: user, plan: plan, order: order)
-
cancel_subscription_for_one_time_purchase(user, plan, order)
-
end
-
-
true
-
end
-
-
def resolve_user(attributes)
-
user_id = dig_custom_user_id(attributes)
-
return User.find_by(id: user_id) if user_id.present?
-
-
email = attributes["user_email"] || attributes["email"] || attributes.dig("checkout_data", "email")
-
return User.find_by(email_address: email.to_s.downcase) if email.present?
-
-
nil
-
end
-
-
def dig_custom_user_id(attributes)
-
attributes.dig("checkout_data", "custom", "user_id") ||
-
attributes.dig("checkout_data", "custom_data", "user_id") ||
-
attributes.dig("custom", "user_id") ||
-
attributes.dig("custom_data", "user_id") ||
-
payload.dig("meta", "custom_data", "user_id") ||
-
payload.dig("meta", "custom", "user_id")
-
end
-
-
def resolve_plan(attributes)
-
variant_id = attributes["variant_id"] ||
-
attributes.dig("first_order_item", "variant_id") ||
-
attributes.dig("variant", "id") ||
-
attributes.dig("variant", "data", "id") ||
-
payload.dig("data", "relationships", "variant", "data", "id")
-
-
return nil if variant_id.blank?
-
-
mapping = Billing::ProviderMapping.find_by(provider: "lemonsqueezy", external_variant_id: variant_id.to_s)
-
mapping&.plan
-
end
-
-
def normalize_status(raw)
-
value = raw.to_s.downcase
-
return "inactive" if value.blank?
-
-
case value
-
when "active" then "active"
-
when "trialing", "on_trial" then "trialing"
-
when "cancelled", "canceled" then "cancelled"
-
when "expired" then "expired"
-
when "past_due" then "past_due"
-
else
-
"inactive"
-
end
-
end
-
-
def parse_time(value)
-
return nil if value.blank?
-
return value if value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone)
-
-
Time.zone.parse(value.to_s)
-
rescue ArgumentError, TypeError
-
nil
-
end
-
-
def truthy?(value)
-
value == true || value.to_s == "true" || value.to_s == "1"
-
end
-
-
# @param normalized [String]
-
# @return [Boolean]
-
def subscription_invoice_event?(normalized)
-
return true if normalized.include?("subscription_payment")
-
return true if normalized.include?("subscription_invoice")
-
-
payload_type = payload.dig("data", "type") || payload.dig("data", "data", "type")
-
payload_type.to_s == "subscription-invoices"
-
end
-
-
# @param attributes [Hash]
-
# @return [Hash]
-
def extract_subscription_urls(attributes)
-
urls = attributes["urls"] || {}
-
{
-
"customer_portal_url" => urls["customer_portal"],
-
"update_payment_method_url" => urls["update_payment_method"],
-
"update_subscription_url" => urls["customer_portal_update_subscription"]
-
}.compact
-
end
-
-
# @param user [User]
-
# @param attributes [Hash]
-
# @param urls [Hash, nil]
-
# @return [Billing::Customer, nil]
-
def sync_customer_mapping(user:, attributes:, urls: nil)
-
external_customer_id = attributes["customer_id"] || attributes["customer"] || payload.dig("meta", "customer_id")
-
return if external_customer_id.blank?
-
-
customer = Billing::Customer.find_or_create_by!(user: user, provider: "lemonsqueezy") do |c|
-
c.external_customer_id = external_customer_id.to_s
-
end
-
-
if customer.external_customer_id != external_customer_id.to_s
-
customer.update!(external_customer_id: external_customer_id.to_s)
-
end
-
-
if urls.present? && urls["customer_portal_url"].present?
-
customer.customer_portal_url = urls["customer_portal_url"]
-
customer.save! if customer.changed?
-
end
-
-
customer
-
end
-
-
# @param order_id [String, Integer, nil]
-
# @param attributes [Hash]
-
# @param receipt_url [String, nil]
-
# @return [Hash]
-
# @param subscription [Billing::Subscription]
-
# @param urls [Hash]
-
# @return [void]
-
def apply_subscription_urls(subscription, urls)
-
return if urls.blank?
-
-
subscription.assign_attributes(urls)
-
end
-
-
# @param user [User]
-
# @param attributes [Hash]
-
# @return [Billing::Subscription, nil]
-
def resolve_subscription_for_order(user, attributes)
-
plan = resolve_plan(attributes)
-
return user.billing_subscriptions.where(provider: "lemonsqueezy").order(updated_at: :desc).first if plan.blank?
-
-
user.billing_subscriptions.find_by(provider: "lemonsqueezy", plan: plan)
-
end
-
-
# @param subscription [Billing::Subscription]
-
# @param order [Billing::Order]
-
# @return [void]
-
def update_subscription_receipt(subscription, order)
-
subscription.latest_receipt_url = order.receipt_url
-
-
metadata = subscription.metadata || {}
-
metadata["latest_order_id"] = order.external_order_id
-
metadata["latest_order_number"] = order.order_number
-
metadata["latest_order_identifier"] = order.identifier
-
metadata["latest_order_status"] = order.status
-
metadata["latest_order_total"] = order.total_cents
-
metadata["latest_order_currency"] = order.currency
-
subscription.update!(metadata: metadata)
-
end
-
-
# @param subscription [Billing::Subscription]
-
# @param customer [Billing::Customer, nil]
-
# @param order_id [String, Integer, nil]
-
# @return [void]
-
def link_order_to_subscription(subscription, customer, order_id)
-
return if order_id.blank?
-
-
order = Billing::Order.find_by(provider: "lemonsqueezy", external_order_id: order_id.to_s)
-
return if order.nil?
-
-
updates = {}
-
updates[:billing_subscription_id] = subscription.id if order.billing_subscription_id != subscription.id
-
updates[:billing_customer_id] = customer.id if customer.present? && order.billing_customer_id != customer.id
-
order.update!(updates) if updates.any?
-
end
-
-
# Creates an EntitlementGrant for one-time purchases (e.g., Sprint plan).
-
#
-
# @param user [User]
-
# @param plan [Billing::Plan]
-
# @param order [Billing::Order]
-
# @return [Billing::EntitlementGrant, nil]
-
def grant_one_time_entitlements(user:, plan:, order:)
-
return unless plan&.one_time?
-
-
# Check for existing grant from the same order to avoid duplicates
-
existing = Billing::EntitlementGrant.find_by(
-
user: user,
-
source: "purchase",
-
reason: "one_time_purchase:#{order.external_order_id}"
-
)
-
return existing if existing.present?
-
-
# Duration from plan metadata or default 30 days
-
duration_days = plan.metadata&.dig("duration_days")&.to_i
-
duration_days = 30 if duration_days.nil? || duration_days <= 0
-
-
starts_at = Time.current
-
expires_at = starts_at + duration_days.days
-
-
# Build entitlements map from plan entitlements
-
entitlements = build_entitlements_from_plan(plan)
-
-
Billing::EntitlementGrant.create!(
-
user: user,
-
plan: plan,
-
source: "purchase",
-
reason: "one_time_purchase:#{order.external_order_id}",
-
starts_at: starts_at,
-
expires_at: expires_at,
-
entitlements: entitlements,
-
metadata: {
-
plan_key: plan.key,
-
plan_name: plan.name,
-
order_id: order.external_order_id,
-
order_number: order.order_number,
-
amount_cents: order.total_cents,
-
currency: order.currency
-
}
-
)
-
end
-
-
# Builds entitlements hash from plan's PlanEntitlements.
-
#
-
# @param plan [Billing::Plan]
-
# @return [Hash] Entitlements map keyed by feature_key
-
def build_entitlements_from_plan(plan)
-
plan.plan_entitlements.includes(:feature).each_with_object({}) do |pe, hash|
-
next unless pe.enabled
-
-
entry = { "enabled" => true }
-
entry["limit"] = pe.limit if pe.limit.present?
-
hash[pe.feature.key] = entry
-
end
-
end
-
-
# Deactivates active one-time purchase grants when a subscription is created/activated.
-
# This ensures only one plan is active at a time.
-
#
-
# @param user [User]
-
# @param subscription [Billing::Subscription]
-
# @return [void]
-
def deactivate_purchase_grants_for_subscription(user, subscription)
-
grants = Billing::EntitlementGrant
-
.where(user: user, source: "purchase")
-
.where("reason LIKE ?", "one_time_purchase:%")
-
.where("starts_at <= ? AND expires_at > ?", Time.current, Time.current)
-
-
return if grants.empty?
-
-
grants.find_each do |grant|
-
grant.update!(
-
expires_at: Time.current,
-
metadata: grant.metadata.merge(
-
"deactivated_reason" => "subscription_activated",
-
"deactivated_at" => Time.current.iso8601,
-
"original_expires_at" => grant.expires_at_was&.iso8601,
-
"subscription_id" => subscription.id
-
)
-
)
-
-
log_info(
-
"deactivated purchase grant for subscription " \
-
"user_id=#{user.id} grant_id=#{grant.id} subscription_id=#{subscription.id}"
-
)
-
end
-
end
-
-
# Cancels active subscriptions when a one-time purchase is made.
-
# This ensures only one plan is active at a time.
-
# Note: This is a safety net; checkout controller also cancels subscriptions.
-
#
-
# @param user [User]
-
# @param plan [Billing::Plan]
-
# @param order [Billing::Order]
-
# @return [void]
-
def cancel_subscription_for_one_time_purchase(user, plan, order)
-
subscriptions = user.billing_subscriptions
-
.where(provider: "lemonsqueezy")
-
.where(status: %w[active trialing])
-
.where(cancel_at_period_end: [ false, nil ])
-
-
return if subscriptions.empty?
-
-
provider = Billing::Providers::LemonSqueezy.new
-
-
subscriptions.find_each do |subscription|
-
begin
-
provider.cancel_subscription(subscription: subscription)
-
-
log_info(
-
"cancelled subscription for one-time purchase " \
-
"user_id=#{user.id} subscription_id=#{subscription.id} order_id=#{order.id} plan=#{plan.key}"
-
)
-
rescue => e
-
notify_error(
-
e,
-
context: "billing",
-
severity: "error",
-
user: user,
-
tags: { provider: "lemonsqueezy", operation: "webhook_cancel_subscription" },
-
subscription_id: subscription.id,
-
order_id: order.id,
-
plan_key: plan.key
-
)
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Billing
-
module Webhooks
-
# Routes webhook events to a provider-specific processor.
-
class Processor
-
# @param webhook_event [Billing::WebhookEvent]
-
def initialize(webhook_event)
-
@webhook_event = webhook_event
-
end
-
-
# @return [void]
-
def run
-
case webhook_event.provider
-
when "lemonsqueezy"
-
Billing::Webhooks::LemonSqueezyProcessor.new(webhook_event).run
-
else
-
webhook_event.update!(status: "ignored", processed_at: Time.current)
-
end
-
end
-
-
private
-
-
attr_reader :webhook_event
-
end
-
end
-
end
-
-
-
# frozen_string_literal: true
-
-
require "httparty"
-
-
# Service for verifying Cloudflare Turnstile tokens
-
class CloudflareTurnstileService
-
VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
-
-
class VerificationError < StandardError; end
-
-
# Verifies a Turnstile token
-
#
-
# @param token [String] The Turnstile token from the client
-
# @param remote_ip [String] The user's IP address
-
# @return [Boolean] True if verification succeeds
-
def self.verify(token, remote_ip = nil)
-
# If Turnstile is not fully configured, allow the request through
-
# (this handles dev environments without keys)
-
return true unless fully_configured?
-
-
if token.blank?
-
Rails.logger.warn "Turnstile verification failed: token is blank"
-
return false
-
end
-
-
response = HTTParty.post(
-
VERIFY_URL,
-
body: {
-
secret: secret_key,
-
response: token,
-
remoteip: remote_ip
-
},
-
timeout: 5
-
)
-
-
result = JSON.parse(response.body)
-
-
unless result["success"]
-
error_codes = result["error-codes"]&.join(", ") || "unknown"
-
Rails.logger.warn "Turnstile verification failed: #{error_codes} (#{result.inspect})"
-
return false
-
end
-
-
true
-
rescue JSON::ParserError, HTTParty::Error, Net::TimeoutError => e
-
Rails.logger.error "Turnstile verification error: #{e.message}"
-
# On network errors, fail open to avoid blocking legitimate users
-
# in case of Cloudflare issues
-
Rails.env.production? ? false : true
-
end
-
-
# Returns the site key for client-side use
-
#
-
# @return [String, nil] The Turnstile site key
-
def self.site_key
-
Rails.application.credentials.dig(:cloudflare, :turnstile_site_key) ||
-
ENV["CLOUDFLARE_TURNSTILE_SITE_KEY"]
-
end
-
-
# Returns the secret key for server-side verification
-
#
-
# @return [String, nil] The Turnstile secret key
-
def self.secret_key
-
Rails.application.credentials.dig(:cloudflare, :turnstile_secret_key) ||
-
ENV["CLOUDFLARE_TURNSTILE_SECRET_KEY"]
-
end
-
-
# Checks if Turnstile is fully configured (both keys present)
-
#
-
# @return [Boolean] True if both site and secret keys are present
-
def self.fully_configured?
-
site_key.present? && secret_key.present?
-
end
-
end
-
# frozen_string_literal: true
-
-
require "digest"
-
-
# Service for computing and persisting a FitAssessment for a user and a fittable.
-
#
-
# The fittable is expected to be owned by the user (same `user_id`).
-
#
-
# Scoring approach (v1):
-
# - Extract job skill mentions by scanning job text for `SkillTag` names (case-insensitive).
-
# - Weight matches by the user's `UserSkill.aggregated_level`.
-
# - Normalize to a 0..100 integer score.
-
#
-
# @example
-
# ComputeFitAssessmentService.new(user: user, fittable: opportunity).call
-
#
-
class ComputeFitAssessmentService
-
ALGORITHM_VERSION = "v1_keyword_skilltag_scan"
-
-
# @param user [User]
-
# @param fittable [Opportunity, SavedJob, InterviewApplication]
-
def initialize(user:, fittable:)
-
@user = user
-
@fittable = fittable
-
end
-
-
# Computes and upserts a FitAssessment.
-
#
-
# @return [Hash] Result hash
-
def call
-
return error_result("User is required") unless @user
-
return error_result("Fittable is required") unless @fittable
-
return error_result("Fittable must belong to user") if @fittable.respond_to?(:user_id) && @fittable.user_id != @user.id
-
-
job_text = build_job_text(@fittable)
-
return upsert_failed("No job text available") if job_text.blank?
-
-
matched = extract_job_skills(job_text)
-
return upsert_failed("No skills found in job text") if matched.empty?
-
-
user_skill_levels = @user.user_skills.pluck(:skill_tag_id, :aggregated_level).to_h
-
-
matched_ids = matched.map { |m| m[:id] }
-
matched_levels = matched_ids.map { |id| user_skill_levels[id].to_f }
-
max_total = matched_ids.size * 5.0
-
-
score = if max_total > 0
-
((matched_levels.sum / max_total) * 100).round.clamp(0, 100)
-
end
-
-
breakdown = build_breakdown(matched, user_skill_levels)
-
inputs_digest = compute_inputs_digest(job_text, user_skill_levels)
-
-
assessment = @fittable.fit_assessment
-
if assessment&.inputs_digest == inputs_digest && assessment.computed?
-
return { success: true, fit_assessment: assessment, skipped: true }
-
end
-
-
assessment ||= @fittable.build_fit_assessment(user: @user)
-
assessment.assign_attributes(
-
score: score,
-
status: :computed,
-
computed_at: Time.current,
-
algorithm_version: ALGORITHM_VERSION,
-
inputs_digest: inputs_digest,
-
breakdown: breakdown
-
)
-
assessment.save!
-
-
{ success: true, fit_assessment: assessment }
-
rescue ActiveRecord::RecordInvalid => e
-
error_result(e.message)
-
rescue StandardError => e
-
Rails.logger.error("ComputeFitAssessmentService failed: #{e.message}")
-
Rails.logger.error(e.backtrace.first(10).join("\n"))
-
error_result(e.message)
-
end
-
-
private
-
-
def upsert_failed(message)
-
assessment = @fittable.fit_assessment || @fittable.build_fit_assessment(user: @user)
-
assessment.assign_attributes(
-
score: nil,
-
status: :failed,
-
computed_at: Time.current,
-
algorithm_version: ALGORITHM_VERSION,
-
inputs_digest: compute_inputs_digest("", {}),
-
breakdown: { error: message }
-
)
-
assessment.save!
-
error_result(message, fit_assessment: assessment)
-
end
-
-
def error_result(message, fit_assessment: nil)
-
{ success: false, error: message, fit_assessment: fit_assessment }
-
end
-
-
def build_job_text(fittable)
-
case fittable
-
when InterviewApplication
-
jl = fittable.job_listing
-
parts = []
-
parts << fittable.display_job_role&.title
-
parts << fittable.display_company&.name
-
if jl
-
parts << jl.title
-
parts << jl.description
-
parts << jl.requirements
-
parts << jl.responsibilities
-
parts << jl.custom_sections&.values&.join("\n")
-
end
-
parts.compact.join("\n")
-
when Opportunity
-
[
-
fittable.job_role_title,
-
fittable.company_name,
-
fittable.key_details,
-
fittable.email_snippet,
-
fittable.job_url
-
].compact.join("\n")
-
when SavedJob
-
if fittable.opportunity
-
build_job_text(fittable.opportunity)
-
else
-
[
-
fittable.title,
-
fittable.job_role_title,
-
fittable.company_name,
-
fittable.notes,
-
fittable.url
-
].compact.join("\n")
-
end
-
else
-
nil
-
end
-
end
-
-
def extract_job_skills(job_text)
-
text = job_text.to_s.downcase
-
tags = SkillTag.pluck(:id, :name)
-
tags.filter_map do |(id, name)|
-
next if name.blank?
-
next unless text.include?(name.downcase)
-
-
{ id: id, name: name }
-
end
-
end
-
-
def build_breakdown(matched, user_skill_levels)
-
matched_ids = matched.map { |m| m[:id] }
-
matched_names = matched.map { |m| m[:name] }
-
-
missing_ids = matched_ids.reject { |id| user_skill_levels.key?(id) }
-
missing_names = matched.select { |m| missing_ids.include?(m[:id]) }.map { |m| m[:name] }
-
-
{
-
method: "skilltag_scan",
-
matched_skills: matched_names.uniq.sort,
-
missing_skills: missing_names.uniq.sort,
-
counts: {
-
matched_in_job: matched_names.uniq.size,
-
matched_in_user: (matched_ids & user_skill_levels.keys).uniq.size,
-
missing_in_user: missing_ids.uniq.size
-
}
-
}
-
end
-
-
def compute_inputs_digest(job_text, user_skill_levels)
-
skills_part = user_skill_levels
-
.sort_by { |k, _| k }
-
.map { |k, v| "#{k}:#{v.round(2)}" }
-
.join("|")
-
-
Digest::SHA256.hexdigest([ ALGORITHM_VERSION, job_text.to_s, skills_part ].join("\n"))
-
end
-
end
-
# frozen_string_literal: true
-
-
# Service for creating or finding a job listing from a URL
-
#
-
# @example
-
# service = CreateJobListingFromUrlService.new(application, "https://example.com/jobs/123")
-
# job_listing = service.call
-
#
-
class CreateJobListingFromUrlService
-
# Initialize the service with an application and URL
-
#
-
# @param [InterviewApplication] application The interview application
-
# @param [String] url The job listing URL
-
def initialize(application, url)
-
@application = application
-
@url = url
-
end
-
-
# Creates or finds a job listing and associates it with the application
-
#
-
# @return [JobListing, nil] The job listing or nil if creation failed
-
def call
-
return nil if @url.blank?
-
-
res = JobListings::UpsertFromUrlService.new(
-
url: @url,
-
company: @application.company,
-
job_role: @application.job_role,
-
title: @application.job_role.title
-
).call
-
-
job_listing = res[:job_listing]
-
-
# Associate with the application
-
@application.update(job_listing: job_listing)
-
-
# Trigger scraping if we haven't successfully scraped yet
-
ScrapeJobListingJob.perform_later(job_listing) unless job_listing.scraped?
-
-
job_listing
-
rescue => e
-
Rails.logger.error "Failed to create job listing from URL: #{e.message}"
-
nil
-
end
-
-
private
-
end
-
# frozen_string_literal: true
-
-
module Dedup
-
# Service for finding possible duplicate categories.
-
#
-
# Heuristics (deterministic):
-
# - Same kind + same normalized name (case-insensitive, ignoring non-alphanumeric chars)
-
class FindCategoryDuplicatesService
-
# @param category [Category]
-
# @param limit [Integer]
-
def initialize(category:, limit: 10)
-
@category = category
-
@limit = limit
-
end
-
-
# @return [Array<Hash>] [{ record: Category, reasons: Array<String> }]
-
def run
-
return [] if @category.nil?
-
-
key = normalize_alnum(@category.name)
-
return [] if key.blank?
-
-
Category
-
.where.not(id: @category.id)
-
.where(kind: @category.kind)
-
.where("LOWER(REGEXP_REPLACE(name, '[^a-z0-9]', '', 'g')) = ?", key)
-
.limit(@limit)
-
.map { |c| { record: c, reasons: [ "Same normalized name within kind" ] } }
-
end
-
-
private
-
-
def normalize_alnum(text)
-
text.to_s.downcase.gsub(/[^a-z0-9]/, "")
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Dedup
-
# Service for finding possible duplicate companies.
-
#
-
# Heuristics (deterministic):
-
# - Same normalized name (case-insensitive, ignoring non-alphanumeric chars)
-
# - Same website host (when present)
-
class FindCompanyDuplicatesService
-
require "uri"
-
-
# @param company [Company]
-
# @param limit [Integer]
-
def initialize(company:, limit: 10)
-
@company = company
-
@limit = limit
-
end
-
-
# @return [Array<Hash>] [{ record: Company, reasons: Array<String> }]
-
def run
-
return [] if @company.nil?
-
-
reasons_by_id = Hash.new { |h, k| h[k] = [] }
-
-
add_name_matches!(reasons_by_id)
-
add_website_host_matches!(reasons_by_id)
-
-
reasons_by_id.map do |id, reasons|
-
{ record: Company.find(id), reasons: reasons.uniq }
-
end.sort_by { |h| -h[:reasons].size }.first(@limit)
-
end
-
-
private
-
-
def add_name_matches!(reasons_by_id)
-
key = normalize_alnum(@company.name)
-
return if key.blank?
-
-
Company
-
.where.not(id: @company.id)
-
.where("LOWER(REGEXP_REPLACE(name, '[^a-z0-9]', '', 'g')) = ?", key)
-
.limit(@limit)
-
.pluck(:id)
-
.each { |id| reasons_by_id[id] << "Same normalized name" }
-
end
-
-
def add_website_host_matches!(reasons_by_id)
-
host = extract_host(@company.website)
-
return if host.blank?
-
-
Company
-
.where.not(id: @company.id)
-
.where("website ILIKE ?", "%#{host}%")
-
.limit(@limit * 3)
-
.find_each do |candidate|
-
next unless extract_host(candidate.website) == host
-
reasons_by_id[candidate.id] << "Same website host (#{host})"
-
end
-
end
-
-
def normalize_alnum(text)
-
text.to_s.downcase.gsub(/[^a-z0-9]/, "")
-
end
-
-
def extract_host(url)
-
return nil if url.blank?
-
-
begin
-
uri = URI.parse(url.strip)
-
host = uri.host
-
host = URI.parse("https://#{url.strip}").host if host.blank?
-
host&.downcase
-
rescue URI::InvalidURIError
-
nil
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Dedup
-
# Service for finding possible duplicate job roles.
-
#
-
# Heuristics (deterministic):
-
# - Same normalized title (case-insensitive, ignoring non-alphanumeric chars)
-
# - Same normalized title + same category_id (stronger signal)
-
class FindJobRoleDuplicatesService
-
# @param job_role [JobRole]
-
# @param limit [Integer]
-
def initialize(job_role:, limit: 10)
-
@job_role = job_role
-
@limit = limit
-
end
-
-
# @return [Array<Hash>] [{ record: JobRole, reasons: Array<String> }]
-
def run
-
return [] if @job_role.nil?
-
-
reasons_by_id = Hash.new { |h, k| h[k] = [] }
-
-
add_title_matches!(reasons_by_id)
-
add_title_and_category_matches!(reasons_by_id)
-
-
reasons_by_id.map do |id, reasons|
-
{ record: JobRole.find(id), reasons: reasons.uniq }
-
end.sort_by { |h| -h[:reasons].size }.first(@limit)
-
end
-
-
private
-
-
def add_title_matches!(reasons_by_id)
-
key = normalize_alnum(@job_role.title)
-
return if key.blank?
-
-
JobRole
-
.where.not(id: @job_role.id)
-
.where("LOWER(REGEXP_REPLACE(title, '[^a-z0-9]', '', 'g')) = ?", key)
-
.limit(@limit)
-
.pluck(:id)
-
.each { |id| reasons_by_id[id] << "Same normalized title" }
-
end
-
-
def add_title_and_category_matches!(reasons_by_id)
-
return if @job_role.category_id.blank?
-
-
key = normalize_alnum(@job_role.title)
-
return if key.blank?
-
-
JobRole
-
.where.not(id: @job_role.id)
-
.where(category_id: @job_role.category_id)
-
.where("LOWER(REGEXP_REPLACE(title, '[^a-z0-9]', '', 'g')) = ?", key)
-
.limit(@limit)
-
.pluck(:id)
-
.each { |id| reasons_by_id[id] << "Same title within same category" }
-
end
-
-
def normalize_alnum(text)
-
text.to_s.downcase.gsub(/[^a-z0-9]/, "")
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Dedup
-
# Service for finding possible duplicate skill tags.
-
#
-
# Heuristics (deterministic):
-
# - Same normalized name (case-insensitive, ignoring non-alphanumeric chars)
-
# - Same normalized name + same category_id (stronger signal)
-
class FindSkillTagDuplicatesService
-
# @param skill_tag [SkillTag]
-
# @param limit [Integer]
-
def initialize(skill_tag:, limit: 10)
-
@skill_tag = skill_tag
-
@limit = limit
-
end
-
-
# @return [Array<Hash>] [{ record: SkillTag, reasons: Array<String> }]
-
def run
-
return [] if @skill_tag.nil?
-
-
reasons_by_id = Hash.new { |h, k| h[k] = [] }
-
-
add_name_matches!(reasons_by_id)
-
add_name_and_category_matches!(reasons_by_id)
-
-
reasons_by_id.map do |id, reasons|
-
{ record: SkillTag.find(id), reasons: reasons.uniq }
-
end.sort_by { |h| -h[:reasons].size }.first(@limit)
-
end
-
-
private
-
-
def add_name_matches!(reasons_by_id)
-
key = normalize_alnum(@skill_tag.name)
-
return if key.blank?
-
-
SkillTag
-
.where.not(id: @skill_tag.id)
-
.where("LOWER(REGEXP_REPLACE(name, '[^a-z0-9]', '', 'g')) = ?", key)
-
.limit(@limit)
-
.pluck(:id)
-
.each { |id| reasons_by_id[id] << "Same normalized name" }
-
end
-
-
def add_name_and_category_matches!(reasons_by_id)
-
return if @skill_tag.category_id.blank?
-
-
key = normalize_alnum(@skill_tag.name)
-
return if key.blank?
-
-
SkillTag
-
.where.not(id: @skill_tag.id)
-
.where(category_id: @skill_tag.category_id)
-
.where("LOWER(REGEXP_REPLACE(name, '[^a-z0-9]', '', 'g')) = ?", key)
-
.limit(@limit)
-
.pluck(:id)
-
.each { |id| reasons_by_id[id] << "Same name within same category" }
-
end
-
-
def normalize_alnum(text)
-
text.to_s.downcase.gsub(/[^a-z0-9]/, "")
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Dedup
-
# Service for merging duplicate categories (within the same kind).
-
#
-
# Repoints all referencing records from a source category to a target category,
-
# then disables the source category (default).
-
#
-
# @example
-
# Dedup::MergeCategoryService.new(source_category: a, target_category: b).run
-
#
-
class MergeCategoryService
-
# @param source_category [Category]
-
# @param target_category [Category]
-
# @param disable_source [Boolean] If true, disable source after merge (default)
-
def initialize(source_category:, target_category:, disable_source: true)
-
@source_category = source_category
-
@target_category = target_category
-
@disable_source = disable_source
-
end
-
-
# Runs the merge.
-
#
-
# @return [Category] The target category
-
def run
-
validate!
-
-
Category.transaction do
-
case @source_category.kind.to_s
-
when "job_role"
-
JobRole.where(category_id: @source_category.id).update_all(category_id: @target_category.id)
-
when "skill_tag"
-
SkillTag.where(category_id: @source_category.id).update_all(category_id: @target_category.id)
-
else
-
raise ArgumentError, "Unsupported category kind: #{@source_category.kind}"
-
end
-
-
finalize_source!
-
end
-
-
@target_category
-
end
-
-
private
-
-
def validate!
-
raise ArgumentError, "source_category is required" if @source_category.nil?
-
raise ArgumentError, "target_category is required" if @target_category.nil?
-
raise ArgumentError, "source_category and target_category must differ" if @source_category.id == @target_category.id
-
raise ArgumentError, "categories must have the same kind" if @source_category.kind != @target_category.kind
-
end
-
-
def finalize_source!
-
if @disable_source
-
@source_category.disable! unless @source_category.disabled?
-
else
-
@source_category.destroy!
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Dedup
-
# Service for merging duplicate companies.
-
#
-
# Moves all associations from a source company to a target company, then disables
-
# the source company (default) to preserve history while preventing future use.
-
#
-
# @example
-
# service = Dedup::MergeCompanyService.new(source_company: a, target_company: b)
-
# service.run
-
#
-
class MergeCompanyService
-
# @param source_company [Company]
-
# @param target_company [Company]
-
# @param disable_source [Boolean] If true, disable source after merge (default)
-
def initialize(source_company:, target_company:, disable_source: true)
-
@source_company = source_company
-
@target_company = target_company
-
@disable_source = disable_source
-
end
-
-
# Runs the merge.
-
#
-
# @return [Company] The target company
-
# @raise [ArgumentError] If source and target are invalid
-
# @raise [ActiveRecord::RecordInvalid] If updates fail
-
def run
-
validate!
-
-
Company.transaction do
-
move_job_listings!
-
move_interview_applications!
-
move_user_targets!
-
move_resume_targets!
-
move_users_current_company!
-
move_email_senders!
-
-
finalize_source!
-
end
-
-
@target_company
-
end
-
-
private
-
-
def validate!
-
raise ArgumentError, "source_company is required" if @source_company.nil?
-
raise ArgumentError, "target_company is required" if @target_company.nil?
-
raise ArgumentError, "source_company and target_company must differ" if @source_company.id == @target_company.id
-
end
-
-
def move_job_listings!
-
JobListing.where(company_id: @source_company.id).update_all(company_id: @target_company.id)
-
end
-
-
def move_interview_applications!
-
InterviewApplication.where(company_id: @source_company.id).update_all(company_id: @target_company.id)
-
end
-
-
def move_users_current_company!
-
User.where(current_company_id: @source_company.id).update_all(current_company_id: @target_company.id)
-
end
-
-
def move_email_senders!
-
EmailSender.where(company_id: @source_company.id).update_all(company_id: @target_company.id)
-
EmailSender.where(auto_detected_company_id: @source_company.id).update_all(auto_detected_company_id: @target_company.id)
-
end
-
-
def move_user_targets!
-
UserTargetCompany.where(company_id: @source_company.id).find_each do |utc|
-
if UserTargetCompany.exists?(user_id: utc.user_id, company_id: @target_company.id)
-
utc.destroy!
-
else
-
utc.update!(company_id: @target_company.id)
-
end
-
end
-
end
-
-
def move_resume_targets!
-
UserResumeTargetCompany.where(company_id: @source_company.id).find_each do |urtc|
-
if UserResumeTargetCompany.exists?(user_resume_id: urtc.user_resume_id, company_id: @target_company.id)
-
urtc.destroy!
-
else
-
urtc.update!(company_id: @target_company.id)
-
end
-
end
-
end
-
-
def finalize_source!
-
if @disable_source
-
@source_company.disable! unless @source_company.disabled?
-
else
-
@source_company.destroy!
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Dedup
-
# Service for merging duplicate job roles.
-
#
-
# Moves all associations from a source job role to a target job role, then disables
-
# the source job role (default) to preserve history while preventing future use.
-
#
-
# @example
-
# Dedup::MergeJobRoleService.new(source_job_role: a, target_job_role: b).run
-
#
-
class MergeJobRoleService
-
# @param source_job_role [JobRole]
-
# @param target_job_role [JobRole]
-
# @param disable_source [Boolean] If true, disable source after merge (default)
-
def initialize(source_job_role:, target_job_role:, disable_source: true)
-
@source_job_role = source_job_role
-
@target_job_role = target_job_role
-
@disable_source = disable_source
-
end
-
-
# Runs the merge.
-
#
-
# @return [JobRole] The target job role
-
def run
-
validate!
-
-
JobRole.transaction do
-
move_job_listings!
-
move_interview_applications!
-
move_user_targets!
-
move_resume_targets!
-
move_users_current_job_role!
-
-
finalize_source!
-
end
-
-
@target_job_role
-
end
-
-
private
-
-
def validate!
-
raise ArgumentError, "source_job_role is required" if @source_job_role.nil?
-
raise ArgumentError, "target_job_role is required" if @target_job_role.nil?
-
raise ArgumentError, "source_job_role and target_job_role must differ" if @source_job_role.id == @target_job_role.id
-
end
-
-
def move_job_listings!
-
JobListing.where(job_role_id: @source_job_role.id).update_all(job_role_id: @target_job_role.id)
-
end
-
-
def move_interview_applications!
-
InterviewApplication.where(job_role_id: @source_job_role.id).update_all(job_role_id: @target_job_role.id)
-
end
-
-
def move_users_current_job_role!
-
User.where(current_job_role_id: @source_job_role.id).update_all(current_job_role_id: @target_job_role.id)
-
end
-
-
def move_user_targets!
-
UserTargetJobRole.where(job_role_id: @source_job_role.id).find_each do |utr|
-
if UserTargetJobRole.exists?(user_id: utr.user_id, job_role_id: @target_job_role.id)
-
utr.destroy!
-
else
-
utr.update!(job_role_id: @target_job_role.id)
-
end
-
end
-
end
-
-
def move_resume_targets!
-
UserResumeTargetJobRole.where(job_role_id: @source_job_role.id).find_each do |urtr|
-
if UserResumeTargetJobRole.exists?(user_resume_id: urtr.user_resume_id, job_role_id: @target_job_role.id)
-
urtr.destroy!
-
else
-
urtr.update!(job_role_id: @target_job_role.id)
-
end
-
end
-
end
-
-
def finalize_source!
-
if @disable_source
-
@source_job_role.disable! unless @source_job_role.disabled?
-
else
-
@source_job_role.destroy!
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Dedup
-
# Service for merging duplicate skill tags.
-
#
-
# Moves join associations from a source skill tag to a target skill tag, handling
-
# uniqueness constraints, then disables the source tag (default).
-
#
-
# @example
-
# Dedup::MergeSkillTagService.new(source_skill_tag: a, target_skill_tag: b).run
-
#
-
class MergeSkillTagService
-
# @param source_skill_tag [SkillTag]
-
# @param target_skill_tag [SkillTag]
-
# @param disable_source [Boolean] If true, disable source after merge (default)
-
def initialize(source_skill_tag:, target_skill_tag:, disable_source: true)
-
@source_skill_tag = source_skill_tag
-
@target_skill_tag = target_skill_tag
-
@disable_source = disable_source
-
end
-
-
# Runs the merge.
-
#
-
# @return [SkillTag] The target skill tag
-
def run
-
validate!
-
-
SkillTag.transaction do
-
move_application_skill_tags!
-
move_resume_skills!
-
move_user_skills!
-
-
finalize_source!
-
end
-
-
@target_skill_tag
-
end
-
-
private
-
-
def validate!
-
raise ArgumentError, "source_skill_tag is required" if @source_skill_tag.nil?
-
raise ArgumentError, "target_skill_tag is required" if @target_skill_tag.nil?
-
raise ArgumentError, "source_skill_tag and target_skill_tag must differ" if @source_skill_tag.id == @target_skill_tag.id
-
end
-
-
def move_application_skill_tags!
-
ApplicationSkillTag.where(skill_tag_id: @source_skill_tag.id).find_each do |join|
-
if ApplicationSkillTag.exists?(interview_id: join.interview_id, skill_tag_id: @target_skill_tag.id)
-
join.destroy!
-
else
-
join.update!(skill_tag_id: @target_skill_tag.id)
-
end
-
end
-
end
-
-
def move_resume_skills!
-
ResumeSkill.where(skill_tag_id: @source_skill_tag.id).find_each do |rs|
-
if ResumeSkill.exists?(user_resume_id: rs.user_resume_id, skill_tag_id: @target_skill_tag.id)
-
rs.destroy!
-
else
-
rs.update!(skill_tag_id: @target_skill_tag.id)
-
end
-
end
-
end
-
-
def move_user_skills!
-
UserSkill.where(skill_tag_id: @source_skill_tag.id).find_each do |us|
-
if UserSkill.exists?(user_id: us.user_id, skill_tag_id: @target_skill_tag.id)
-
us.destroy!
-
else
-
us.update!(skill_tag_id: @target_skill_tag.id)
-
end
-
end
-
end
-
-
def finalize_source!
-
if @disable_source
-
@source_skill_tag.disable! unless @source_skill_tag.disabled?
-
else
-
@source_skill_tag.destroy!
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# Service for analyzing interview feedback and generating AI summaries
-
class FeedbackAnalysisService
-
# @param interview_feedback [InterviewFeedback] The interview feedback to analyze
-
def initialize(interview_feedback)
-
@interview_feedback = interview_feedback
-
end
-
-
# Analyzes the feedback and generates summary and tags
-
# @return [Hash] Hash containing ai_summary and tags
-
def analyze
-
# TODO: Implement actual AI analysis using OpenAI/Anthropic API
-
# For now, return placeholder data
-
{
-
ai_summary: generate_placeholder_summary,
-
tags: extract_placeholder_tags,
-
recommended_action: generate_placeholder_recommendation
-
}
-
end
-
-
# Generates AI summary for the feedback
-
# @return [String] Generated summary
-
def generate_summary
-
# TODO: Implement actual AI summary generation
-
generate_placeholder_summary
-
end
-
-
# Extracts skill tags from feedback
-
# @return [Array<String>] Array of extracted tags
-
def extract_tags
-
# TODO: Implement actual tag extraction
-
extract_placeholder_tags
-
end
-
-
# Generates a recommended action
-
# @return [String] Recommended action
-
def generate_recommendation
-
# TODO: Implement actual recommendation generation
-
generate_placeholder_recommendation
-
end
-
-
private
-
-
def generate_placeholder_summary
-
strengths = @interview_feedback.went_well.present? ? "strong performance in discussed areas" : "areas to celebrate"
-
improvements = @interview_feedback.to_improve.present? ? "opportunities for growth identified" : "room for development"
-
-
"You showed #{strengths}. There are #{improvements}. Continue building on your strengths while addressing areas for improvement."
-
end
-
-
def extract_placeholder_tags
-
# Simple keyword extraction as placeholder
-
text = [
-
@interview_feedback.went_well,
-
@interview_feedback.to_improve,
-
@interview_feedback.self_reflection
-
].compact.join(" ")
-
-
common_skills = [
-
"Communication", "System Design", "Problem Solving",
-
"Leadership", "Technical Skills", "Collaboration"
-
]
-
-
common_skills.select { |skill| text.downcase.include?(skill.downcase) }
-
end
-
-
def generate_placeholder_recommendation
-
return nil if @interview_feedback.to_improve.blank?
-
-
"Focus on practicing the areas you identified for improvement. Consider mock interviews or study sessions targeting these specific topics."
-
end
-
end
-
-
# frozen_string_literal: true
-
-
# Service for managing Gmail API client connections
-
#
-
# @example
-
# service = Gmail::ClientService.new(connected_account)
-
# client = service.client
-
#
-
class Gmail::ClientService
-
# @return [ConnectedAccount] The connected account
-
attr_reader :connected_account
-
-
# Initialize the client service
-
#
-
# @param connected_account [ConnectedAccount] The connected account with OAuth tokens
-
def initialize(connected_account)
-
@connected_account = connected_account
-
end
-
-
# Returns a configured Gmail API client
-
#
-
# @return [Google::Apis::GmailV1::GmailService]
-
# @raise [Gmail::TokenExpiredError] If the token is expired and can't be refreshed
-
def client
-
refresh_token_if_needed!
-
-
@client ||= begin
-
service = Google::Apis::GmailV1::GmailService.new
-
service.authorization = authorization
-
service
-
end
-
end
-
-
# Returns the user's email address (me)
-
#
-
# @return [String]
-
def user_id
-
"me"
-
end
-
-
private
-
-
# Creates an authorization object for the API
-
#
-
# @return [Signet::OAuth2::Client]
-
def authorization
-
Signet::OAuth2::Client.new(
-
client_id: Rails.application.credentials.dig(:google, :client_id),
-
client_secret: Rails.application.credentials.dig(:google, :client_secret),
-
token_credential_uri: "https://oauth2.googleapis.com/token",
-
access_token: connected_account.access_token,
-
refresh_token: connected_account.refresh_token,
-
expires_at: connected_account.expires_at
-
)
-
end
-
-
# Refreshes the token if it's expired or expiring soon
-
#
-
# @return [void]
-
# @raise [Gmail::TokenExpiredError] If the token can't be refreshed
-
def refresh_token_if_needed!
-
return unless connected_account.token_expired? || connected_account.token_expiring_soon?
-
return unless connected_account.refreshable?
-
-
refresh_token!
-
end
-
-
# Refreshes the access token using the refresh token
-
#
-
# @return [void]
-
# @raise [Gmail::TokenExpiredError] If the refresh fails
-
def refresh_token!
-
auth = authorization
-
auth.refresh!
-
-
connected_account.update!(
-
access_token: auth.access_token,
-
expires_at: Time.at(auth.expires_at)
-
)
-
-
# Reset the client to use new tokens
-
@client = nil
-
rescue Signet::AuthorizationError => e
-
Rails.logger.error "Gmail token refresh failed: #{e.message}"
-
raise Gmail::Errors::TokenExpiredError, "Failed to refresh Gmail token. Please reconnect your account."
-
end
-
end
-
# frozen_string_literal: true
-
-
# Service for matching email senders to companies
-
# Analyzes email domains and content to auto-detect company associations
-
#
-
# @example
-
# matcher = Gmail::CompanyMatcherService.new
-
# company = matcher.find_company_for_email("recruiter@company.com")
-
#
-
class Gmail::CompanyMatcherService
-
# Known ATS system domains that should be associated with the sending company, not the ATS
-
ATS_DOMAINS = Gmail::SyncService::RECRUITER_DOMAINS.freeze
-
-
# Common email provider domains to ignore
-
GENERIC_DOMAINS = %w[
-
gmail.com yahoo.com hotmail.com outlook.com live.com
-
icloud.com me.com mac.com aol.com mail.com
-
protonmail.com pm.me tutanota.com zoho.com
-
yandex.com gmx.com fastmail.com
-
].freeze
-
-
# Initialize the service
-
def initialize
-
@domain_cache = {}
-
end
-
-
# Finds or auto-detects a company for an email address
-
#
-
# @param email [String] The email address
-
# @param sender_name [String, nil] The sender's display name
-
# @return [Company, nil]
-
def find_company_for_email(email, sender_name = nil)
-
return nil if email.blank?
-
-
domain = extract_domain(email)
-
return nil if generic_domain?(domain)
-
-
# Check cache first
-
return @domain_cache[domain] if @domain_cache.key?(domain)
-
-
# Try to find company
-
company = find_by_domain(domain) ||
-
find_by_website(domain) ||
-
find_by_name_from_domain(domain) ||
-
find_by_sender_name(sender_name)
-
-
@domain_cache[domain] = company
-
company
-
end
-
-
# Processes an email sender and updates company associations
-
#
-
# @param email_sender [EmailSender] The sender to process
-
# @return [Hash] Result with detected company info
-
def process_sender(email_sender)
-
return { success: false, error: "No sender provided" } unless email_sender
-
-
company = find_company_for_email(email_sender.email, email_sender.name)
-
-
if company
-
email_sender.update!(auto_detected_company: company) unless email_sender.company_id.present?
-
{ success: true, company: company, auto_detected: true }
-
else
-
{ success: true, company: nil, auto_detected: false }
-
end
-
rescue StandardError => e
-
Rails.logger.warn "CompanyMatcher failed for #{email_sender.email}: #{e.message}"
-
{ success: false, error: e.message }
-
end
-
-
# Bulk processes all unassigned senders
-
#
-
# @param limit [Integer] Maximum senders to process
-
# @return [Hash] Processing statistics
-
def process_unassigned_senders(limit: 100)
-
senders = EmailSender.unassigned.where(auto_detected_company_id: nil).limit(limit)
-
stats = { processed: 0, matched: 0, unmatched: 0, errors: 0 }
-
-
senders.find_each do |sender|
-
result = process_sender(sender)
-
stats[:processed] += 1
-
-
if result[:success]
-
result[:company] ? stats[:matched] += 1 : stats[:unmatched] += 1
-
else
-
stats[:errors] += 1
-
end
-
end
-
-
stats
-
end
-
-
# Finds all senders for a specific domain
-
#
-
# @param domain [String] The email domain
-
# @return [Array<EmailSender>]
-
def senders_for_domain(domain)
-
EmailSender.by_domain(domain).order(:email)
-
end
-
-
# Assigns a company to all senders from a domain
-
#
-
# @param domain [String] The email domain
-
# @param company [Company] The company to assign
-
# @param verify [Boolean] Whether to mark as verified
-
# @return [Integer] Number of senders updated
-
def assign_domain_to_company(domain, company, verify: true)
-
EmailSender.by_domain(domain).update_all(
-
company_id: company.id,
-
verified: verify,
-
updated_at: Time.current
-
)
-
end
-
-
private
-
-
# Extracts domain from email address
-
#
-
# @param email [String]
-
# @return [String]
-
def extract_domain(email)
-
email.to_s.split("@").last&.downcase&.strip || ""
-
end
-
-
# Checks if domain is a generic email provider
-
#
-
# @param domain [String]
-
# @return [Boolean]
-
def generic_domain?(domain)
-
GENERIC_DOMAINS.include?(domain.downcase)
-
end
-
-
# Checks if domain is an ATS system
-
#
-
# @param domain [String]
-
# @return [Boolean]
-
def ats_domain?(domain)
-
ATS_DOMAINS.any? { |ats| domain.include?(ats) }
-
end
-
-
# Finds company by existing email sender domain association
-
#
-
# @param domain [String]
-
# @return [Company, nil]
-
def find_by_domain(domain)
-
# Check if we've already associated this domain with a company
-
existing_sender = EmailSender.by_domain(domain)
-
.where.not(company_id: nil)
-
.first
-
-
existing_sender&.company
-
end
-
-
# Finds company by website URL containing domain
-
#
-
# @param domain [String]
-
# @return [Company, nil]
-
def find_by_website(domain)
-
return nil if ats_domain?(domain)
-
-
Company.where("website ILIKE ?", "%#{domain}%").first ||
-
Company.where("website ILIKE ?", "%#{domain.split('.').first}%").first
-
end
-
-
# Finds company by matching domain name to company name
-
#
-
# @param domain [String]
-
# @return [Company, nil]
-
def find_by_name_from_domain(domain)
-
return nil if ats_domain?(domain)
-
-
# Extract the main part of the domain (e.g., "google" from "google.com")
-
domain_name = domain.split(".").first
-
return nil if domain_name.length < 3
-
-
# Try exact match first
-
Company.where("LOWER(name) = ?", domain_name.downcase).first ||
-
# Try partial match
-
Company.where("LOWER(name) LIKE ?", "%#{domain_name.downcase}%")
-
.where("LENGTH(name) < ?", domain_name.length + 10) # Avoid matching "Google Inc" to "goo"
-
.first
-
end
-
-
# Finds company from sender's display name
-
#
-
# @param sender_name [String, nil]
-
# @return [Company, nil]
-
def find_by_sender_name(sender_name)
-
return nil if sender_name.blank?
-
-
# Extract potential company name from formats like:
-
# "Jane at Company" or "Company Recruiting" or "Company HR"
-
patterns = [
-
/(?:at|from|with)\s+([A-Z][A-Za-z0-9\s&]+?)(?:\s|$)/i,
-
/^([A-Z][A-Za-z0-9\s&]+?)\s+(?:Recruiting|HR|Talent|Team|Careers?)/i
-
]
-
-
patterns.each do |pattern|
-
match = sender_name.match(pattern)
-
if match
-
company_name = match[1].strip
-
company = Company.where("LOWER(name) = ?", company_name.downcase).first
-
return company if company
-
end
-
end
-
-
nil
-
end
-
end
-
# frozen_string_literal: true
-
-
# Service for processing synced emails to classify type and match to applications
-
#
-
# @example
-
# processor = Gmail::EmailProcessorService.new(synced_email)
-
# result = processor.run
-
#
-
class Gmail::EmailProcessorService
-
PROXY_SENDER_DOMAINS = %w[
-
linkedin.com
-
mail.linkedin.com
-
].freeze
-
-
PROXY_SENDER_EMAILS = %w[
-
inmail-hit-reply@linkedin.com
-
].freeze
-
-
# Keywords for detecting email types
-
EMAIL_TYPE_PATTERNS = {
-
interview_invite: [
-
/interview\s+(invitation|invite|scheduled|confirmed)/i,
-
/schedule\s+(a|an|your|the)\s+interview/i,
-
/invit(e|ing)\s+you\s+(to|for)\s+(an?\s+)?interview/i,
-
/would\s+like\s+to\s+interview/i,
-
/meet\s+with\s+(our|the)\s+team/i,
-
/phone\s+screen/i,
-
/technical\s+interview/i,
-
/on-?site\s+interview/i,
-
/video\s+interview/i,
-
/zoom\s+(interview|call|meeting)/i,
-
# Subject line patterns (high signal)
-
/\b(first|initial|final|next|second|third)\s+interview\b/i,
-
/interview\s+(with|at)\s+\w+/i,
-
# Recruiter scheduling interview
-
/I\s+recruit/i,
-
/recruiter\s+(at|for|from)/i,
-
/set\s+up\s+(a\s+)?time\s+(for\s+us\s+)?to\s+(chat|talk|meet|speak)/i,
-
/excited\s+to\s+(get\s+to\s+)?know\s+you/i
-
],
-
scheduling: [
-
/schedule\s+(a\s+|the\s+)?(call|meeting|time)/i,
-
/book\s+(a\s+)?time/i,
-
/calendly/i,
-
/goodtime\.io/i,
-
/pick\s+a\s+time/i,
-
/available\s+times?/i,
-
/when\s+are\s+you\s+available/i,
-
/set\s+up\s+(a\s+)?time/i,
-
/visit\s+this\s+link/i
-
],
-
application_confirmation: [
-
/thank\s+you\s+for\s+(applying|your\s+application)/i,
-
/application\s+(received|submitted|confirmed)/i,
-
/we\s+(have\s+)?received\s+your\s+application/i,
-
/successfully\s+applied/i,
-
/application\s+for\s+.+\s+position/i
-
],
-
rejection: [
-
/we\s+(regret|unfortunately|are\s+sorry)/i,
-
/not\s+(be\s+)?moving\s+forward/i,
-
/decided\s+(not\s+)?to\s+proceed/i,
-
/position\s+has\s+been\s+filled/i,
-
/not\s+a\s+(good\s+)?fit/i,
-
/won'?t\s+be\s+(moving|proceeding)/i,
-
/pursuing\s+other\s+candidates/i
-
],
-
offer: [
-
/offer\s+(letter|of\s+employment)/i,
-
/pleased\s+to\s+offer/i,
-
/extend(ing)?\s+(an?\s+)?offer/i,
-
/job\s+offer/i,
-
/congratulations/i,
-
/welcome\s+to\s+the\s+team/i,
-
/excited\s+to\s+have\s+you\s+join/i
-
],
-
assessment: [
-
/coding\s+(challenge|test|assessment)/i,
-
/take-?home\s+(assignment|test|project)/i,
-
/technical\s+assessment/i,
-
/skills?\s+assessment/i,
-
/hackerrank/i,
-
/codility/i,
-
/leetcode/i,
-
/complete\s+the\s+(following\s+)?assessment/i
-
],
-
follow_up: [
-
/following\s+up/i,
-
/checking\s+in/i,
-
/wanted\s+to\s+follow\s+up/i,
-
/any\s+updates?/i,
-
/status\s+of\s+(my|your)\s+application/i
-
],
-
thank_you: [
-
/thank\s+you\s+for\s+(your\s+time|meeting|interviewing)/i,
-
/great\s+meeting\s+you/i,
-
/enjoyed\s+(speaking|talking|meeting)/i
-
],
-
recruiter_outreach: [
-
/exciting\s+(opportunity|role|position)/i,
-
/perfect\s+fit/i,
-
/great\s+fit/i,
-
/your\s+(profile|background|experience)/i,
-
/reaching\s+out/i,
-
/interested\s+in\s+you/i,
-
/open\s+position/i,
-
/hiring\s+for/i,
-
/would\s+you\s+be\s+interested/i,
-
/great\s+match/i,
-
/ideal\s+candidate/i,
-
/thought\s+of\s+you/i,
-
/came\s+across\s+your/i,
-
/found\s+your\s+profile/i,
-
/saw\s+your\s+resume/i,
-
/impressive\s+background/i,
-
/looking\s+for\s+someone/i,
-
/we\s+have\s+an\s+opening/i,
-
/new\s+opportunity/i,
-
/career\s+opportunity/i
-
],
-
round_feedback: [
-
# Pass/move forward patterns
-
/you('ve| have)?\s+(passed|cleared|moved forward)/i,
-
/pleased\s+to\s+inform\s+you/i,
-
/congratulations.*next\s+(round|stage)/i,
-
/moving\s+(you\s+)?(forward|ahead)/i,
-
/advancing\s+to\s+(the\s+)?next/i,
-
/proceed(ing)?\s+to\s+(the\s+)?(next|final)/i,
-
/happy\s+to\s+share.*(passed|moved)/i,
-
/great\s+news.*(passed|next\s+round)/i,
-
# Rejection patterns (for single round, not full rejection)
-
/unfortunately.*not\s+(moving|proceeding)/i,
-
/decided\s+not\s+to\s+move\s+forward/i,
-
# Feedback patterns
-
/feedback\s+(from|on)\s+your\s+(interview|round)/i,
-
/interview\s+feedback/i,
-
/results?\s+(of|from)\s+(your\s+)?interview/i,
-
/outcome\s+(of|from)\s+(your\s+)?interview/i,
-
/update\s+on\s+your\s+(interview|round)/i,
-
# Waitlist patterns
-
/waitlist(ed)?/i,
-
/hold\s+for\s+now/i,
-
/keep\s+you\s+in\s+mind/i
-
]
-
}.freeze
-
-
# Priority order when multiple email types match
-
EMAIL_TYPE_PRIORITY = %w[
-
rejection
-
round_feedback
-
offer
-
assessment
-
scheduling
-
interview_invite
-
application_confirmation
-
follow_up
-
thank_you
-
recruiter_outreach
-
].freeze
-
-
SELF_SENT_TYPE_BLACKLIST = %w[
-
rejection
-
round_feedback
-
offer
-
].freeze
-
-
# @return [SyncedEmail] The email to process
-
attr_reader :synced_email
-
-
# Initialize the processor
-
#
-
# @param synced_email [SyncedEmail] The email to process
-
# @param pipeline_run [Signals::EmailPipelineRun, nil] Optional pipeline run for observability
-
def initialize(synced_email, pipeline_run: nil)
-
@synced_email = synced_email
-
@pipeline_recorder = Signals::Observability::EmailPipelineRecorder.for_run(pipeline_run)
-
end
-
-
# Runs the processing pipeline
-
#
-
# @return [Hash] Processing result
-
def run
-
return already_processed_result if synced_email.processed? || synced_email.ignored?
-
-
ActiveRecord::Base.transaction do
-
if pipeline_recorder
-
pipeline_recorder.measure(:email_classification) do
-
classify_email_type
-
{ "email_type" => synced_email.email_type }
-
end
-
-
pipeline_recorder.measure(:company_detection) do
-
detect_company
-
{ "detected_company" => synced_email.detected_company }
-
end
-
-
pipeline_recorder.measure(:application_match) do
-
match_to_application
-
{ "interview_application_id" => synced_email.interview_application_id }
-
end
-
else
-
classify_email_type
-
detect_company
-
match_to_application
-
end
-
synced_email.save!
-
end
-
-
{
-
success: true,
-
email_type: synced_email.email_type,
-
matched_application: synced_email.interview_application_id,
-
detected_company: synced_email.detected_company
-
}
-
rescue StandardError => e
-
Rails.logger.error "Email processing failed for #{synced_email.id}: #{e.message}"
-
synced_email.mark_failed!(e.message)
-
{ success: false, error: e.message }
-
end
-
-
private
-
-
# Returns result for already processed emails
-
#
-
# @return [Hash]
-
def already_processed_result
-
{
-
success: true,
-
email_type: synced_email.email_type,
-
matched_application: synced_email.interview_application_id,
-
already_processed: true
-
}
-
end
-
-
def pipeline_recorder
-
@pipeline_recorder
-
end
-
-
# Classifies the email type based on content patterns
-
# Emails from target companies get boosted relevance
-
#
-
# @return [void]
-
def classify_email_type
-
# LinkedIn and similar proxy senders can include misleading keywords (e.g. "JOB OFFER")
-
# in the subject even when the content is just recruiter outreach.
-
if proxy_sender?
-
detector = Gmail::OpportunityDetectorService.new(synced_email)
-
if detector.recruiter_outreach?
-
synced_email.email_type = "recruiter_outreach"
-
return
-
end
-
end
-
-
content = classification_content
-
matched_types = EMAIL_TYPE_PATTERNS.filter_map do |type, patterns|
-
type.to_s if patterns.any? { |pattern| content.match?(pattern) }
-
end
-
-
if self_sent_email?
-
matched_types -= SELF_SENT_TYPE_BLACKLIST
-
end
-
-
# "job offer" alone is not strong enough signal for proxy senders (LinkedIn InMail, etc.)
-
if proxy_sender? && matched_types.include?("offer") && !strong_offer_signal?(content)
-
matched_types -= [ "offer" ]
-
end
-
-
if matched_types.any?
-
synced_email.email_type = choose_email_type(matched_types)
-
return
-
end
-
-
# Boost relevance: If from a target company but no pattern matched,
-
# classify as recruiter_outreach since it's likely relevant
-
if from_target_company?
-
synced_email.email_type = "recruiter_outreach"
-
return
-
end
-
-
# Default to "other" if no pattern matched and not from target company
-
synced_email.email_type = "other"
-
end
-
-
# Chooses the email type based on priority order
-
#
-
# @param matched_types [Array<String>]
-
# @return [String]
-
def choose_email_type(matched_types)
-
EMAIL_TYPE_PRIORITY.find { |type| matched_types.include?(type) } || matched_types.first
-
end
-
-
# Builds the content used for email classification
-
#
-
# @return [String]
-
def classification_content
-
subject = synced_email.subject.to_s
-
body = primary_body_content
-
return [ subject, body ].join(" ").strip if body.present?
-
-
[ subject, synced_email.snippet, synced_email.body_preview ].compact.join(" ")
-
end
-
-
# Extracts the primary body content (removes quoted replies/forwards)
-
#
-
# @return [String]
-
def primary_body_content
-
body = synced_email.body_preview.presence || synced_email.snippet.to_s
-
return "" if body.blank?
-
-
lines = body.split("\n")
-
cutoff = lines.index { |line| reply_separator?(line) }
-
trimmed_lines = cutoff ? lines[0...cutoff] : lines
-
trimmed_lines = trimmed_lines.reject { |line| line.lstrip.start_with?(">") }
-
trimmed = trimmed_lines.join("\n")
-
trimmed = trimmed.strip
-
trimmed.presence || body
-
end
-
-
# Checks if a line indicates the start of quoted content
-
#
-
# @param line [String]
-
# @return [Boolean]
-
def reply_separator?(line)
-
normalized = line.to_s.strip
-
[
-
/^On .+ wrote:$/i,
-
/^On .+sent:$/i,
-
/^On .+wrote$/i,
-
/^From:\s+/i,
-
/^Sent:\s+/i,
-
/^To:\s+/i,
-
/^Subject:\s+/i,
-
/^-----Original Message-----/i,
-
/^----- Forwarded message -----/i,
-
/^Begin forwarded message:/i
-
].any? { |pattern| normalized.match?(pattern) }
-
end
-
-
# Checks if the email was sent by the user
-
#
-
# @return [Boolean]
-
def self_sent_email?
-
sender = synced_email.from_email.to_s.downcase
-
return false if sender.blank?
-
-
account_email = synced_email.connected_account&.email.to_s.downcase
-
user_email = synced_email.user&.email_address.to_s.downcase
-
-
sender == account_email || sender == user_email
-
end
-
-
# Checks if the email is from a company the user is targeting
-
#
-
# @return [Boolean]
-
def from_target_company?
-
return false if synced_email.from_email.blank?
-
-
sender_domain = synced_email.from_email.split("@").last&.downcase
-
return false if sender_domain.blank? || generic_domain?(sender_domain)
-
-
target_domains = user_target_company_domains
-
target_domains.any? do |company_domain|
-
sender_domain == company_domain ||
-
sender_domain.end_with?(".#{company_domain}") ||
-
company_domain.end_with?(".#{sender_domain}")
-
end
-
end
-
-
# Returns email domains for the user's target companies
-
#
-
# @return [Array<String>]
-
def user_target_company_domains
-
@user_target_company_domains ||= synced_email.user.target_companies.filter_map do |company|
-
next unless company.website.present?
-
-
url = company.website.strip
-
url = "https://#{url}" unless url.start_with?("http")
-
-
uri = URI.parse(url)
-
uri.host&.gsub(/^www\./, "")&.downcase
-
rescue URI::InvalidURIError
-
nil
-
end.uniq
-
end
-
-
# Detects company name from email content
-
#
-
# @return [void]
-
def detect_company
-
# Try sender's company first
-
if synced_email.email_sender&.effective_company
-
synced_email.detected_company = synced_email.email_sender.effective_company.name
-
return
-
end
-
-
# Try to extract from email domain
-
domain = synced_email.from_email.split("@").last
-
company = find_company_by_domain(domain)
-
-
if company
-
synced_email.detected_company = company.name
-
# Also update the sender's auto-detected company
-
synced_email.email_sender&.update(auto_detected_company: company)
-
return
-
end
-
-
# Try to extract company name from subject or content
-
extracted_name = extract_company_from_content
-
synced_email.detected_company = extracted_name if extracted_name
-
end
-
-
# Finds a company by email domain
-
#
-
# @param domain [String] The email domain
-
# @return [Company, nil]
-
def find_company_by_domain(domain)
-
return nil if domain.blank? || generic_domain?(domain)
-
-
# Try exact website match
-
Company.where("website ILIKE ?", "%#{domain}%").first ||
-
# Try company name match
-
Company.where("LOWER(name) = ?", extract_company_from_domain(domain).downcase).first
-
end
-
-
# Checks if domain is a generic email provider
-
#
-
# @param domain [String]
-
# @return [Boolean]
-
def generic_domain?(domain)
-
generic_domains = %w[
-
gmail.com yahoo.com hotmail.com outlook.com
-
icloud.com aol.com mail.com protonmail.com
-
]
-
generic_domains.include?(domain.downcase)
-
end
-
-
# Extracts company name from domain
-
#
-
# @param domain [String]
-
# @return [String]
-
def extract_company_from_domain(domain)
-
# Remove common TLDs and get the main part
-
domain.split(".").first.titleize
-
end
-
-
# Extracts company name from email content
-
#
-
# @return [String, nil]
-
def extract_company_from_content
-
content = "#{synced_email.subject} #{synced_email.snippet}"
-
-
# Pattern: "at [Company]" or "from [Company]" or "[Company] Team"
-
patterns = [
-
/(?:at|from|with)\s+([A-Z][A-Za-z0-9\s&]+?)(?:\s+team|\s+inc|\s+llc|\s+corp|,|\.|!|\?|$)/i,
-
/([A-Z][A-Za-z0-9\s&]+?)\s+(?:team|recruiting|talent|hr)\s+/i,
-
/application\s+(?:for|to|at)\s+([A-Z][A-Za-z0-9\s&]+)/i
-
]
-
-
patterns.each do |pattern|
-
match = content.match(pattern)
-
return match[1].strip if match && match[1].length > 2 && match[1].length < 50
-
end
-
-
nil
-
end
-
-
# Matches the email to an existing application
-
#
-
# @return [void]
-
def match_to_application
-
return if synced_email.interview_application_id.present?
-
-
application = find_matching_application
-
if application
-
synced_email.interview_application = application
-
synced_email.status = :processed
-
else
-
# Leave as pending for manual review
-
synced_email.status = :pending
-
end
-
end
-
-
# Finds an application that matches this email
-
#
-
# Uses multiple strategies in order of reliability:
-
# 1. Thread-based matching (same conversation = same application)
-
# 2. Sender consistency (emails from same person go to same application)
-
# 3. Company name matching
-
# 4. Sender's assigned company
-
# 5. Domain-based matching
-
#
-
# @return [InterviewApplication, nil]
-
def find_matching_application
-
user = synced_email.user
-
-
# Proxy senders (e.g. LinkedIn InMail) can only auto-match with high confidence.
-
# We treat "same thread already matched" as high-confidence, but we never use
-
# sender-consistency or other heuristics for proxy senders.
-
if proxy_sender?
-
return match_proxy_sender_by_thread(user)
-
end
-
-
# Strategy 1: Match by email thread (same thread = same application)
-
# This is highest priority to maintain conversation continuity
-
if synced_email.thread_id.present?
-
existing = SyncedEmail.where(user: user, thread_id: synced_email.thread_id)
-
.where.not(interview_application_id: nil)
-
.first
-
return existing.interview_application if existing
-
end
-
-
# Strategy 2: Match by sender consistency (same sender = same application)
-
# If we already have emails from this sender matched to an active application,
-
# keep them together to avoid splitting conversations across applications
-
if synced_email.from_email.present?
-
existing = SyncedEmail.where(user: user, from_email: synced_email.from_email)
-
.where.not(interview_application_id: nil)
-
.joins(:interview_application)
-
.where(interview_applications: { status: :active })
-
.order(email_date: :desc)
-
.first
-
return existing.interview_application if existing
-
end
-
-
# Strategy 3: Match by company name
-
if synced_email.detected_company.present?
-
company = Company.where("LOWER(name) = ?", synced_email.detected_company.downcase).first
-
if company
-
app = user.interview_applications
-
.where(company: company)
-
.where(status: :active)
-
.order(created_at: :desc)
-
.first
-
return app if app
-
end
-
end
-
-
# Strategy 4: Match by sender's company
-
if synced_email.email_sender&.effective_company
-
app = user.interview_applications
-
.where(company: synced_email.email_sender.effective_company)
-
.where(status: :active)
-
.order(created_at: :desc)
-
.first
-
return app if app
-
end
-
-
# Strategy 5: Match by sender domain to application companies
-
app = match_by_sender_domain(user)
-
return app if app
-
-
nil
-
end
-
-
# Matches email to application by comparing sender domain to company websites
-
#
-
# @param user [User] The user
-
# @return [InterviewApplication, nil]
-
def match_by_sender_domain(user)
-
sender_domain = synced_email.from_email.split("@").last&.downcase
-
return nil if sender_domain.blank? || generic_domain?(sender_domain)
-
-
# Find applications where the company website matches the sender domain
-
user.interview_applications
-
.includes(:company)
-
.where(status: :active)
-
.find do |app|
-
company = app.company
-
next unless company&.website.present?
-
-
company_domain = extract_domain_from_website(company.website)
-
next unless company_domain
-
-
# Check if domains match
-
sender_domain == company_domain ||
-
sender_domain.end_with?(".#{company_domain}") ||
-
company_domain.end_with?(".#{sender_domain}")
-
end
-
end
-
-
# Extracts domain from a website URL
-
#
-
# @param website [String] The website URL
-
# @return [String, nil]
-
def extract_domain_from_website(website)
-
url = website.strip
-
url = "https://#{url}" unless url.start_with?("http")
-
-
uri = URI.parse(url)
-
uri.host&.gsub(/^www\./, "")&.downcase
-
rescue URI::InvalidURIError
-
nil
-
end
-
-
def proxy_sender?
-
from = synced_email.from_email.to_s.downcase
-
return true if PROXY_SENDER_EMAILS.include?(from)
-
-
domain = from.split("@").last
-
return false if domain.blank?
-
-
PROXY_SENDER_DOMAINS.include?(domain)
-
end
-
-
def match_proxy_sender_by_thread(user)
-
return nil if synced_email.thread_id.blank?
-
-
app_ids =
-
SyncedEmail.where(user: user, thread_id: synced_email.thread_id)
-
.where.not(interview_application_id: nil)
-
.distinct
-
.pluck(:interview_application_id)
-
-
return nil unless app_ids.size == 1
-
-
user.interview_applications.find_by(id: app_ids.first, status: :active) ||
-
user.interview_applications.find_by(id: app_ids.first)
-
end
-
-
def strong_offer_signal?(content)
-
return false if content.blank?
-
-
[
-
/offer\s+(letter|of\s+employment)/i,
-
/pleased\s+to\s+offer/i,
-
/extend(ing)?\s+(an?\s+)?offer/i,
-
/welcome\s+to\s+the\s+team/i,
-
/excited\s+to\s+have\s+you\s+join/i
-
].any? { |pattern| content.match?(pattern) }
-
end
-
end
-
# frozen_string_literal: true
-
-
# Gmail service exceptions
-
module Gmail
-
module Errors
-
class TokenExpiredError < StandardError; end
-
class AuthorizationError < Google::Apis::AuthorizationError; end
-
class RateLimitError < Google::Apis::RateLimitError; end
-
class ServerError < Google::Apis::ServerError; end
-
class ClientError < Google::Apis::ClientError; end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Gmail
-
# Service for detecting recruiter outreach emails
-
# Distinguishes between application-related emails and unsolicited recruiter contact
-
#
-
# @example
-
# detector = Gmail::OpportunityDetectorService.new(synced_email)
-
# if detector.recruiter_outreach?
-
# opportunity = detector.create_opportunity!
-
# end
-
#
-
class OpportunityDetectorService
-
# Keywords that indicate recruiter outreach (not application-related)
-
OUTREACH_KEYWORDS = [
-
# Direct outreach phrases
-
"opportunity",
-
"exciting role",
-
"perfect fit",
-
"great fit",
-
"your profile",
-
"your background",
-
"your experience",
-
"reaching out",
-
"interested in you",
-
"open position",
-
"hiring for",
-
"would you be interested",
-
"great match",
-
"ideal candidate",
-
"thought of you",
-
"came across your",
-
"found your profile",
-
"saw your resume",
-
"impressive background",
-
"looking for someone",
-
"we have an opening",
-
"new opportunity",
-
"career opportunity"
-
].freeze
-
-
# Keywords that indicate this is a reply to an application (NOT outreach)
-
APPLICATION_KEYWORDS = [
-
"thank you for applying",
-
"your application",
-
"application received",
-
"application status",
-
"interview scheduled",
-
"interview confirmed",
-
"next steps in the process",
-
"move forward with your",
-
"following up on your application"
-
].freeze
-
-
# Domains that typically send recruiter outreach
-
RECRUITER_DOMAINS = [
-
"linkedin.com",
-
"mail.linkedin.com",
-
"hired.com",
-
"angel.co",
-
"wellfound.com",
-
"dice.com",
-
"indeed.com",
-
"ziprecruiter.com",
-
"glassdoor.com",
-
"monster.com"
-
].freeze
-
-
# Title patterns for recruiters
-
RECRUITER_TITLE_PATTERNS = [
-
/recruiter/i,
-
/talent\s*(acquisition|partner|scout)/i,
-
/sourcer/i,
-
/headhunter/i,
-
/staffing/i,
-
/hr\s*manager/i,
-
/hiring\s*manager/i,
-
/people\s*ops/i
-
].freeze
-
-
# Patterns indicating forwarded messages
-
FORWARDED_PATTERNS = [
-
/fwd?:/i,
-
/forwarded message/i,
-
/---------- Forwarded message/i,
-
/Begin forwarded message/i
-
].freeze
-
-
# LinkedIn-specific patterns
-
LINKEDIN_PATTERNS = [
-
/linkedin\.com/i,
-
/sent you a message/i,
-
/wants to connect/i,
-
/InMail/i,
-
/via LinkedIn/i
-
].freeze
-
-
# @return [SyncedEmail] The email to analyze
-
attr_reader :synced_email
-
-
# Initialize the detector
-
#
-
# @param synced_email [SyncedEmail] The email to analyze
-
def initialize(synced_email)
-
@synced_email = synced_email
-
end
-
-
# Checks if this email is recruiter outreach
-
#
-
# @return [Boolean] True if this appears to be recruiter outreach
-
def recruiter_outreach?
-
return false if application_related?
-
-
outreach_score >= 0.5
-
end
-
-
# Returns a confidence score for recruiter outreach detection
-
#
-
# @return [Float] Score between 0 and 1
-
def outreach_score
-
score = 0.0
-
total_weight = 0.0
-
-
# Check outreach keywords (weight: 0.4)
-
if has_outreach_keywords?
-
score += 0.4
-
end
-
total_weight += 0.4
-
-
# Check recruiter sender patterns (weight: 0.3)
-
if from_recruiter?
-
score += 0.3
-
end
-
total_weight += 0.3
-
-
# Check for LinkedIn/job board forwarding (weight: 0.2)
-
if forwarded_from_job_platform?
-
score += 0.2
-
end
-
total_weight += 0.2
-
-
# Check if first contact (no thread history) (weight: 0.1)
-
if first_contact?
-
score += 0.1
-
end
-
total_weight += 0.1
-
-
score / total_weight
-
end
-
-
# Detects the source type of the opportunity
-
#
-
# @return [String] One of: direct_email, linkedin_forward, referral, other
-
def detect_source_type
-
if linkedin_forward?
-
"linkedin_forward"
-
elsif has_referral_indicators?
-
"referral"
-
elsif from_recruiter?
-
"direct_email"
-
else
-
"other"
-
end
-
end
-
-
# Checks if this is a forwarded email
-
#
-
# @return [Boolean]
-
def forwarded?
-
content = combined_content
-
FORWARDED_PATTERNS.any? { |pattern| content.match?(pattern) }
-
end
-
-
# Checks if this is forwarded from LinkedIn
-
#
-
# @return [Boolean]
-
def linkedin_forward?
-
content = combined_content
-
from_domain = extract_domain(synced_email.from_email)
-
-
# Check if from LinkedIn or mentions LinkedIn
-
from_domain == "linkedin.com" ||
-
from_domain == "mail.linkedin.com" ||
-
LINKEDIN_PATTERNS.any? { |pattern| content.match?(pattern) }
-
end
-
-
# Creates an Opportunity from this email
-
#
-
# @return [Opportunity] The created opportunity
-
def create_opportunity!
-
Opportunity.create!(
-
user: synced_email.user,
-
synced_email: synced_email,
-
status: "new",
-
source_type: detect_source_type,
-
recruiter_name: synced_email.from_name,
-
recruiter_email: synced_email.from_email,
-
email_snippet: synced_email.snippet || synced_email.body_preview&.truncate(500),
-
ai_confidence_score: outreach_score,
-
extracted_data: {
-
is_forwarded: forwarded?,
-
original_source: detect_original_source
-
}
-
)
-
end
-
-
private
-
-
# Returns combined content for analysis
-
#
-
# @return [String]
-
def combined_content
-
[
-
synced_email.subject,
-
synced_email.snippet,
-
synced_email.body_preview
-
].compact.join(" ").downcase
-
end
-
-
# Checks if content has outreach keywords
-
#
-
# @return [Boolean]
-
def has_outreach_keywords?
-
content = combined_content
-
OUTREACH_KEYWORDS.any? { |keyword| content.include?(keyword.downcase) }
-
end
-
-
# Checks if this appears to be application-related (not outreach)
-
#
-
# @return [Boolean]
-
def application_related?
-
content = combined_content
-
APPLICATION_KEYWORDS.any? { |keyword| content.include?(keyword.downcase) }
-
end
-
-
# Checks if sender appears to be a recruiter
-
#
-
# @return [Boolean]
-
def from_recruiter?
-
# Check sender name for recruiter title patterns
-
if synced_email.from_name.present?
-
return true if RECRUITER_TITLE_PATTERNS.any? { |pattern| synced_email.from_name.match?(pattern) }
-
end
-
-
# Check if from recruiter domain
-
domain = extract_domain(synced_email.from_email)
-
RECRUITER_DOMAINS.include?(domain)
-
end
-
-
# Checks if forwarded from a job platform
-
#
-
# @return [Boolean]
-
def forwarded_from_job_platform?
-
linkedin_forward? || (forwarded? && from_recruiter?)
-
end
-
-
# Checks if this is the first contact in a thread
-
#
-
# @return [Boolean]
-
def first_contact?
-
return true if synced_email.thread_id.blank?
-
-
synced_email.thread_count == 1
-
end
-
-
# Checks for referral indicators
-
#
-
# @return [Boolean]
-
def has_referral_indicators?
-
content = combined_content
-
referral_patterns = [
-
/referred by/i,
-
/recommended you/i,
-
/suggested I reach out/i,
-
/your colleague/i,
-
/mutual connection/i
-
]
-
referral_patterns.any? { |pattern| content.match?(pattern) }
-
end
-
-
# Detects the original source of the opportunity
-
#
-
# @return [String, nil]
-
def detect_original_source
-
if linkedin_forward?
-
"linkedin"
-
elsif forwarded?
-
"forwarded"
-
else
-
nil
-
end
-
end
-
-
# Extracts domain from email address
-
#
-
# @param email [String] Email address
-
# @return [String] Domain portion
-
def extract_domain(email)
-
return "" if email.blank?
-
-
email.split("@").last&.downcase || ""
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# Service for syncing emails from Gmail that may contain interview-related content
-
#
-
# @example
-
# service = Gmail::SyncService.new(connected_account)
-
# result = service.run
-
#
-
class Gmail::SyncService < ApplicationService
-
# Keywords that indicate an email might be interview-related
-
INTERVIEW_KEYWORDS = [
-
"interview",
-
"interviewing",
-
"phone screen",
-
"technical interview",
-
"coding challenge",
-
"assessment",
-
"hiring",
-
"application status",
-
"job application",
-
"thank you for applying",
-
"next steps",
-
"schedule a call",
-
"meet the team",
-
"offer letter",
-
"job offer",
-
"congratulations",
-
"we regret",
-
"unfortunately",
-
"position has been filled"
-
].freeze
-
-
# Keywords that indicate recruiter outreach (new opportunity)
-
RECRUITER_OUTREACH_KEYWORDS = [
-
"opportunity",
-
"exciting role",
-
"perfect fit",
-
"your profile",
-
"your background",
-
"reaching out",
-
"interested in you",
-
"open position",
-
"hiring for",
-
"would you be interested",
-
"great match",
-
"ideal candidate",
-
"came across your"
-
].freeze
-
-
# Common recruiting email domains
-
RECRUITER_DOMAINS = [
-
"greenhouse.io",
-
"lever.co",
-
"workday.com",
-
"icims.com",
-
"taleo.net",
-
"jobvite.com",
-
"smartrecruiters.com",
-
"ashbyhq.com",
-
"bamboohr.com"
-
].freeze
-
-
# Domains that send recruiter outreach (LinkedIn, job boards)
-
OUTREACH_DOMAINS = [
-
"linkedin.com",
-
"mail.linkedin.com",
-
"hired.com",
-
"angel.co",
-
"wellfound.com",
-
"dice.com",
-
"indeed.com",
-
"ziprecruiter.com"
-
].freeze
-
-
# Patterns that indicate an email is NOT job-related (marketing, newsletters, etc.)
-
IRRELEVANT_PATTERNS = [
-
/unsubscribe.*preferences/i,
-
/weekly\s+digest/i,
-
/daily\s+digest/i,
-
/newsletter/i,
-
/marketing\s+email/i,
-
/promotional/i,
-
/you\s+might\s+like/i,
-
/trending\s+(jobs?|posts?|articles?)/i,
-
/people\s+you\s+may\s+know/i,
-
/people\s+viewed\s+your\s+profile/i,
-
/who\s+viewed\s+your\s+profile/i,
-
/your\s+network\s+updates?/i,
-
/connection\s+request/i,
-
/wants\s+to\s+connect/i,
-
/endorsed\s+you/i,
-
/congratulate/i,
-
/work\s+anniversary/i,
-
/birthday/i,
-
/new\s+job\s+alert/i,
-
/jobs?\s+you\s+may\s+be\s+interested/i,
-
/similar\s+jobs?/i,
-
/job\s+recommendations?/i,
-
/security\s+alert/i,
-
/password\s+reset/i,
-
/verify\s+your\s+email/i,
-
/confirm\s+your\s+email/i,
-
/account\s+update/i,
-
/privacy\s+policy/i,
-
/terms\s+of\s+service/i
-
].freeze
-
-
# Subjects that indicate generic platform notifications (not direct recruiter contact)
-
NOTIFICATION_SUBJECTS = [
-
/^you\s+have\s+\d+\s+new/i,
-
/^your\s+daily\s+job/i,
-
/^your\s+weekly/i,
-
/^new\s+jobs?\s+for\s+you/i,
-
/^jobs?\s+matching\s+your/i,
-
/^people\s+are\s+looking/i,
-
/^who'?s\s+viewed/i,
-
/^you\s+appeared\s+in/i,
-
/^\d+\s+new\s+(jobs?|messages?|notifications?)/i
-
].freeze
-
-
# @return [ConnectedAccount] The connected account
-
attr_reader :connected_account
-
-
# @return [User] The user
-
attr_reader :user
-
-
# @return [Integer] Maximum number of emails to process per sync
-
attr_reader :max_results
-
-
# Initialize the sync service
-
#
-
# @param connected_account [ConnectedAccount] The connected account with OAuth tokens
-
# @param max_results [Integer] Maximum number of emails to fetch (default: 100)
-
def initialize(connected_account, max_results: 100)
-
@connected_account = connected_account
-
@user = connected_account.user
-
@max_results = max_results
-
@synced_emails = []
-
end
-
-
# Runs the sync process
-
#
-
# @return [Hash] Results of the sync
-
def run
-
return { success: false, error: "Account not connected" } unless connected_account&.google?
-
return { success: false, error: "Sync disabled" } unless connected_account.sync_enabled?
-
-
begin
-
emails = fetch_interview_emails
-
parsed_emails = parse_emails(emails)
-
-
# Store and process emails
-
sync_results = store_and_process_emails(parsed_emails)
-
-
connected_account.mark_synced!
-
-
{
-
success: true,
-
emails_found: emails.size,
-
emails_parsed: parsed_emails.size,
-
emails_new: sync_results[:new_count],
-
emails_processed: sync_results[:processed_count],
-
emails_matched: sync_results[:matched_count],
-
opportunities_created: sync_results[:opportunities_count],
-
synced_at: Time.current
-
}
-
rescue Gmail::Errors::TokenExpiredError => e
-
{ success: false, error: e.message, needs_reauth: true }
-
rescue Google::Apis::Error => e
-
Rails.logger.error "Gmail API error: #{e.message}"
-
notify_error(
-
e,
-
context: "gmail_sync",
-
severity: "error",
-
user: user,
-
operation: "gmail_api",
-
connected_account_id: connected_account.id
-
)
-
{ success: false, error: "Gmail API error: #{e.message}" }
-
rescue StandardError => e
-
Rails.logger.error "Gmail sync error: #{e.class} - #{e.message}"
-
Rails.logger.error e.backtrace.first(10).join("\n")
-
notify_error(
-
e,
-
context: "gmail_sync",
-
severity: "error",
-
user: user,
-
operation: "sync_run",
-
connected_account_id: connected_account.id
-
)
-
{ success: false, error: "Sync failed: #{e.message}" }
-
end
-
end
-
-
# Returns counts of synced emails by status
-
#
-
# @return [Hash]
-
def sync_stats
-
{
-
total: user.synced_emails.from_account(connected_account).count,
-
pending: user.synced_emails.from_account(connected_account).pending.count,
-
processed: user.synced_emails.from_account(connected_account).processed.count,
-
matched: user.synced_emails.from_account(connected_account).matched.count,
-
opportunities: user.opportunities.actionable.count
-
}
-
end
-
-
private
-
-
# Returns the Gmail client
-
#
-
# @return [Gmail::ClientService]
-
def client_service
-
@client_service ||= Gmail::ClientService.new(connected_account)
-
end
-
-
# Returns the Gmail API client
-
#
-
# @return [Google::Apis::GmailV1::GmailService]
-
def gmail
-
client_service.client
-
end
-
-
# Fetches emails that may be interview-related
-
#
-
# @return [Array<Google::Apis::GmailV1::Message>]
-
def fetch_interview_emails
-
# Build a search query for interview-related emails
-
query = build_search_query
-
-
# Get messages matching the query
-
response = gmail.list_user_messages(
-
client_service.user_id,
-
q: query,
-
max_results: max_results
-
)
-
-
return [] unless response.messages
-
-
# Fetch full message details for each message
-
response.messages.map do |message_ref|
-
gmail.get_user_message(client_service.user_id, message_ref.id)
-
end
-
end
-
-
# Builds the Gmail search query
-
# Refined to reduce irrelevant emails while still catching relevant ones
-
#
-
# @return [String]
-
def build_search_query
-
# Search for emails from the last 30 days
-
after_date = 30.days.ago.strftime("%Y/%m/%d")
-
-
# Build keyword query for interview-related emails (high signal)
-
interview_keyword_query = INTERVIEW_KEYWORDS.map { |kw| "\"#{kw}\"" }.join(" OR ")
-
-
# Build keyword query for recruiter outreach
-
outreach_keyword_query = RECRUITER_OUTREACH_KEYWORDS.map { |kw| "\"#{kw}\"" }.join(" OR ")
-
-
# Build domain query for ATS systems (these are always relevant)
-
ats_domain_query = RECRUITER_DOMAINS.map { |d| "from:#{d}" }.join(" OR ")
-
-
# Exclusions to filter out noise
-
exclusions = [
-
"-subject:\"unsubscribe\"",
-
"-subject:\"newsletter\"",
-
"-subject:\"digest\"",
-
"-subject:\"weekly jobs\"",
-
"-subject:\"daily jobs\"",
-
"-subject:\"job alert\"",
-
"-subject:\"jobs for you\"",
-
"-subject:\"people you may know\"",
-
"-subject:\"who viewed your profile\"",
-
"-subject:\"connection request\"",
-
"-from:noreply",
-
"-from:no-reply",
-
"-from:notifications@",
-
"-from:marketing@"
-
].join(" ")
-
-
# Combine all queries - look for keyword matches OR from ATS systems
-
# Note: We removed OUTREACH_DOMAINS (LinkedIn, Indeed, etc.) from the OR clause
-
# because they generate too much noise. Instead, we rely on keywords to catch
-
# relevant recruiter outreach from those platforms.
-
all_keywords = "(#{interview_keyword_query} OR #{outreach_keyword_query})"
-
ats_only = "(#{ats_domain_query})"
-
-
# Also fetch emails from companies user has applied to or is targeting
-
# These are always relevant regardless of keywords
-
user_company_query = user_company_email_query
-
-
# Also fetch emails from known recruiters for this user
-
known_recruiter_query = known_recruiter_sender_query
-
-
query_parts = [ all_keywords, ats_only, user_company_query, known_recruiter_query ].compact
-
-
"after:#{after_date} in:inbox -in:spam -in:trash #{exclusions} (#{query_parts.join(' OR ')})"
-
end
-
-
# Returns email domains for companies the user has applied to or is targeting
-
# These companies are always relevant for email sync
-
#
-
# @return [Array<String>] List of email domains
-
def user_company_email_domains
-
@user_company_email_domains ||= begin
-
# Get companies from applications and targets
-
applied_companies = user.interview_applications
-
.includes(:company)
-
.where(status: :active)
-
.map(&:company)
-
.compact
-
-
target_companies = user.target_companies.to_a
-
-
# Combine and dedupe
-
all_companies = (applied_companies + target_companies).uniq
-
-
# Extract domains from company websites
-
all_companies.filter_map do |company|
-
extract_domain_from_company(company)
-
end.uniq
-
end
-
end
-
-
# Builds a Gmail query fragment for user company domains
-
#
-
# @return [String, nil] Query fragment or nil if no domains
-
def user_company_email_query
-
user_domains = user_company_email_domains
-
return nil if user_domains.empty?
-
-
"(#{user_domains.map { |d| "from:#{d}" }.join(' OR ')})"
-
end
-
-
# Builds a Gmail query fragment for known recruiter senders
-
# Includes known recruiter emails and names from prior synced emails
-
#
-
# @return [String, nil] Query fragment or nil if no known senders
-
def known_recruiter_sender_query
-
sender_types = %w[recruiter hr hiring_manager]
-
senders = EmailSender.joins(:synced_emails)
-
.where(synced_emails: { user_id: user.id })
-
.where(sender_type: sender_types)
-
.order(last_seen_at: :desc)
-
.limit(25)
-
-
sender_emails = senders.map(&:email).filter_map do |email|
-
next if email.blank?
-
"from:#{email}"
-
end
-
-
sender_ids = senders.map(&:id)
-
sender_names = if sender_ids.any?
-
user.synced_emails
-
.where(email_sender_id: sender_ids)
-
.where.not(from_name: [ nil, "" ])
-
.distinct
-
.limit(10)
-
.pluck(:from_name)
-
else
-
[]
-
end
-
-
name_terms = sender_names.filter_map do |name|
-
clean_name = name.to_s.delete('"').strip
-
next if clean_name.blank?
-
%(from:"#{clean_name}")
-
end
-
-
terms = (sender_emails + name_terms).uniq
-
return nil if terms.empty?
-
-
"(#{terms.join(' OR ')})"
-
end
-
-
# Extracts email domain from a company's website
-
#
-
# @param company [Company] The company
-
# @return [String, nil] The domain (e.g., "google.com")
-
def extract_domain_from_company(company)
-
return nil unless company.website.present?
-
-
# Parse website URL to get domain
-
url = company.website.strip
-
url = "https://#{url}" unless url.start_with?("http")
-
-
uri = URI.parse(url)
-
domain = uri.host&.gsub(/^www\./, "")
-
-
# Skip generic domains
-
return nil if domain.blank? || generic_email_domain?(domain)
-
-
domain
-
rescue URI::InvalidURIError
-
nil
-
end
-
-
# Checks if a domain is a generic email provider (not company-specific)
-
#
-
# @param domain [String] The domain to check
-
# @return [Boolean]
-
def generic_email_domain?(domain)
-
generic = %w[
-
gmail.com yahoo.com hotmail.com outlook.com
-
icloud.com aol.com mail.com protonmail.com
-
live.com msn.com ymail.com
-
]
-
generic.include?(domain.downcase)
-
end
-
-
# Parses email messages into structured data
-
#
-
# @param messages [Array<Google::Apis::GmailV1::Message>]
-
# @return [Array<Hash>]
-
def parse_emails(messages)
-
messages.filter_map { |message| parse_email(message) }
-
end
-
-
# Parses a single email message
-
#
-
# @param message [Google::Apis::GmailV1::Message]
-
# @return [Hash, nil]
-
def parse_email(message)
-
headers = extract_headers(message)
-
body_content = extract_body_content(message)
-
-
{
-
id: message.id,
-
thread_id: message.thread_id,
-
subject: headers["Subject"],
-
from: headers["From"],
-
to: headers["To"],
-
date: parse_date(headers["Date"]),
-
snippet: message.snippet,
-
labels: message.label_ids,
-
body_preview: body_content[:plain],
-
body_html: body_content[:html]
-
}
-
rescue StandardError => e
-
Rails.logger.error "Failed to parse email #{message.id}: #{e.class} - #{e.message}"
-
notify_error(
-
e,
-
context: "gmail_sync",
-
severity: "warning",
-
user: user,
-
gmail_message_id: message.id
-
)
-
nil
-
end
-
-
# Extracts headers from a message
-
#
-
# @param message [Google::Apis::GmailV1::Message]
-
# @return [Hash]
-
def extract_headers(message)
-
return {} unless message.payload&.headers
-
-
message.payload.headers.each_with_object({}) do |header, hash|
-
hash[header.name] = header.value
-
end
-
end
-
-
# Parses a date string from email headers
-
#
-
# @param date_string [String]
-
# @return [DateTime, nil]
-
def parse_date(date_string)
-
return nil unless date_string
-
-
DateTime.parse(date_string)
-
rescue ArgumentError
-
nil
-
end
-
-
# Extracts both plain text and HTML body content from email
-
#
-
# @param message [Google::Apis::GmailV1::Message]
-
# @return [Hash] { plain: String, html: String|nil }
-
def extract_body_content(message)
-
result = { plain: message.snippet.to_s, html: nil }
-
return result unless message.payload
-
-
# Extract raw HTML body (stored as-is for rendering)
-
html_body = extract_body_part(message.payload, "text/html")
-
if html_body.present?
-
# Store HTML as-is but limit size to prevent huge emails
-
result[:html] = html_body.truncate(100_000, omission: "") # 100KB limit for HTML
-
end
-
-
# Extract plain text for preview and search
-
plain_body = extract_body_part(message.payload, "text/plain")
-
-
if plain_body.present?
-
result[:plain] = clean_plain_text(plain_body)
-
elsif html_body.present?
-
# Convert HTML to plain text if no plain text version exists
-
result[:plain] = clean_plain_text(ActionController::Base.helpers.strip_tags(html_body))
-
end
-
-
result
-
end
-
-
# Cleans up plain text content for storage
-
#
-
# @param text [String] Raw plain text
-
# @return [String] Cleaned text
-
def clean_plain_text(text)
-
return "" if text.blank?
-
-
text.gsub(/\r\n?/, "\n") # Normalize line endings
-
.gsub(/\n{3,}/, "\n\n") # Max 2 consecutive newlines
-
.gsub(/[ \t]+/, " ") # Collapse horizontal whitespace
-
.strip
-
.truncate(10_000) # Store up to 10KB of plain text
-
end
-
-
# Recursively extracts body part by MIME type
-
#
-
# The Google APIs gem may return body.data in two formats:
-
# 1. Already decoded plain text (most common with format='full')
-
# 2. Base64 encoded (URL-safe variant) which needs decoding
-
#
-
# @param part [Google::Apis::GmailV1::MessagePart]
-
# @param mime_type [String]
-
# @return [String, nil]
-
def extract_body_part(part, mime_type)
-
if part.mime_type == mime_type && part.body&.data.present?
-
return decode_body_data(part.body.data)
-
end
-
-
return nil unless part.parts
-
-
part.parts.each do |sub_part|
-
result = extract_body_part(sub_part, mime_type)
-
return result if result
-
end
-
-
nil
-
rescue StandardError => e
-
Rails.logger.error "Failed to extract body part (#{mime_type}): #{e.class} - #{e.message}"
-
notify_error(
-
e,
-
context: "gmail_sync",
-
severity: "warning",
-
user: user,
-
operation: "extract_body_part",
-
mime_type: mime_type
-
)
-
nil
-
end
-
-
# Decodes body data from Gmail API
-
#
-
# The Gmail API gem sometimes returns already-decoded data and sometimes
-
# returns Base64 encoded data. This method handles both cases.
-
#
-
# @param data [String] The body data (may be decoded or Base64 encoded)
-
# @return [String, nil] The decoded content
-
def decode_body_data(data)
-
return nil if data.blank?
-
-
# Check if data looks like Base64 (only contains valid Base64 chars)
-
# Base64 URL-safe uses: A-Z, a-z, 0-9, -, _, and optional = padding
-
if data.match?(/\A[A-Za-z0-9_-]+={0,2}\z/) && data.length > 50
-
# Likely Base64 encoded - try to decode
-
begin
-
decoded = Base64.urlsafe_decode64(data)
-
# Verify it produced valid UTF-8 text
-
decoded.force_encoding("UTF-8")
-
return decoded if decoded.valid_encoding?
-
rescue ArgumentError
-
# Not valid Base64, fall through to use raw data
-
end
-
end
-
-
# Data is already decoded plain text or HTML
-
# Force UTF-8 encoding and handle any invalid bytes
-
data.dup.force_encoding("UTF-8").scrub
-
end
-
-
# Stores parsed emails and processes them
-
#
-
# @param parsed_emails [Array<Hash>] Parsed email data
-
# @return [Hash] Processing statistics
-
def store_and_process_emails(parsed_emails)
-
stats = { new_count: 0, processed_count: 0, matched_count: 0, opportunities_count: 0, auto_ignored_count: 0 }
-
-
parsed_emails.each do |email_data|
-
# Create or find existing synced email
-
synced_email = SyncedEmail.create_from_gmail_message(user, connected_account, email_data)
-
next unless synced_email
-
-
# Track if this is a new email
-
# NOTE: We intentionally rely on the record's create/save changes instead of
-
# created_at comparisons. Gmail sync can take longer than a minute, and we
-
# also may see the same message again within a minute (which would otherwise
-
# create confusing "no-op" pipeline runs with only synced_email_upsert).
-
is_new_email = synced_email.previous_changes.key?("id")
-
stats[:new_count] += 1 if is_new_email
-
-
recorder =
-
if is_new_email
-
Signals::Observability::EmailPipelineRecorder.start_for(
-
synced_email: synced_email,
-
user: user,
-
connected_account: connected_account,
-
trigger: "gmail_sync",
-
mode: "mixed",
-
metadata: {
-
"feature_flags" => {
-
"signals_decision_shadow_enabled" => Setting.signals_decision_shadow_enabled?,
-
"signals_decision_execution_enabled" => Setting.signals_decision_execution_enabled?,
-
"signals_email_facts_extraction_enabled" => Setting.signals_email_facts_extraction_enabled?
-
}
-
}
-
)
-
end
-
-
recorder&.event!(
-
event_type: :synced_email_upsert,
-
status: :success,
-
output_payload: {
-
"synced_email_id" => synced_email.id,
-
"status" => synced_email.status,
-
"extraction_status" => synced_email.extraction_status
-
}
-
)
-
-
# Auto-ignore clearly irrelevant emails (marketing, notifications, etc.)
-
if is_new_email && synced_email.pending? && clearly_irrelevant?(synced_email)
-
synced_email.update!(status: :auto_ignored)
-
stats[:auto_ignored_count] += 1
-
recorder&.finish_success!(metadata: { "final" => "auto_ignored", "reason" => "clearly_irrelevant" })
-
next
-
end
-
-
# Process the email if it's pending
-
if synced_email.pending?
-
result = Gmail::EmailProcessorService.new(synced_email, pipeline_run: recorder&.run).run
-
stats[:processed_count] += 1 if result[:success]
-
-
# Auto-ignore emails classified as "other" (not job-related)
-
if result[:email_type] == "other" && !synced_email.matched?
-
synced_email.update!(status: :auto_ignored)
-
stats[:auto_ignored_count] += 1
-
recorder&.finish_success!(metadata: { "final" => "auto_ignored", "reason" => "classified_other_unmatched" })
-
next
-
end
-
-
# Check for recruiter outreach and create opportunity
-
if result[:email_type] == "recruiter_outreach" && synced_email.opportunity.blank?
-
unless Setting.signals_decision_opportunity_creation_enabled?
-
opportunity = create_opportunity_from_email(synced_email)
-
stats[:opportunities_count] += 1 if opportunity
-
end
-
elsif synced_email.reload.matched?
-
stats[:matched_count] += 1
-
end
-
-
# Queue signal extraction for relevant emails
-
if is_new_email
-
queued = queue_signal_extraction(synced_email, run_id: recorder&.run&.id)
-
if queued
-
recorder&.event!(
-
event_type: :signal_extraction_enqueued,
-
status: :success,
-
output_payload: { "job" => "ProcessSignalExtractionJob", "synced_email_id" => synced_email.id }
-
)
-
else
-
recorder&.finish_success!(metadata: { "final" => "no_job_enqueued" })
-
end
-
else
-
recorder&.finish_success!(metadata: { "final" => "processed_existing" }) if recorder
-
end
-
elsif synced_email.matched?
-
stats[:matched_count] += 1
-
recorder&.finish_success!(metadata: { "final" => "matched_existing" }) if recorder
-
end
-
end
-
-
stats
-
end
-
-
# Checks if an email is clearly irrelevant (marketing, notifications, etc.)
-
# These are platform emails that slipped through the Gmail query but aren't direct recruiter contact
-
#
-
# @param synced_email [SyncedEmail] The email to check
-
# @return [Boolean] True if the email should be auto-ignored
-
def clearly_irrelevant?(synced_email)
-
# NEVER auto-ignore emails from companies user has applied to or is targeting
-
# These are always relevant regardless of content patterns
-
return false if from_user_company?(synced_email)
-
-
content = [
-
synced_email.subject,
-
synced_email.snippet,
-
synced_email.body_preview
-
].compact.join(" ")
-
-
# Check against irrelevant patterns (newsletters, notifications, etc.)
-
return true if IRRELEVANT_PATTERNS.any? { |pattern| content.match?(pattern) }
-
-
# Check if subject matches notification-style subjects
-
return true if NOTIFICATION_SUBJECTS.any? { |pattern| synced_email.subject&.match?(pattern) }
-
-
# LinkedIn-specific: ignore if from LinkedIn but not a direct recruiter message
-
if synced_email.from_email.include?("linkedin.com")
-
# These are typically automated notifications, not direct recruiter outreach
-
linkedin_notification_patterns = [
-
/jobs-noreply/i,
-
/messages-noreply/i,
-
/notifications-noreply/i,
-
/invitations/i,
-
/member@linkedin/i
-
]
-
return true if linkedin_notification_patterns.any? { |p| synced_email.from_email.match?(p) }
-
end
-
-
# Indeed/ZipRecruiter job alerts (not direct applications)
-
if synced_email.from_email.match?(/indeed|ziprecruiter/i)
-
return true if synced_email.subject&.match?(/jobs?\s+(for\s+you|matching|alert|digest)/i)
-
end
-
-
false
-
end
-
-
# Checks if an email is from a company the user has applied to or is targeting
-
#
-
# @param synced_email [SyncedEmail] The email to check
-
# @return [Boolean] True if from a user's company
-
def from_user_company?(synced_email)
-
return false if synced_email.from_email.blank?
-
-
sender_domain = synced_email.from_email.split("@").last&.downcase
-
return false if sender_domain.blank?
-
-
# Check if sender domain matches any user company domain
-
user_company_email_domains.any? do |company_domain|
-
# Match if domains are equal or one contains the other
-
# (handles cases like "mail.google.com" vs "google.com")
-
sender_domain == company_domain ||
-
sender_domain.end_with?(".#{company_domain}") ||
-
company_domain.end_with?(".#{sender_domain}")
-
end
-
end
-
-
# Queues signal extraction for a synced email
-
# Extracts company info, recruiter details, job information, and suggested actions
-
#
-
# @param synced_email [SyncedEmail] The synced email
-
# @return [void]
-
def queue_signal_extraction(synced_email, run_id: nil)
-
# Only queue if email is suitable for extraction
-
return false if synced_email.auto_ignored? || synced_email.ignored?
-
return false if synced_email.email_type == "other" && !synced_email.matched?
-
return false if synced_email.extraction_status != "pending"
-
-
ProcessSignalExtractionJob.perform_later(synced_email.id, run_id)
-
true
-
end
-
-
# Creates an opportunity from a recruiter outreach email
-
#
-
# @param synced_email [SyncedEmail] The synced email
-
# @return [Opportunity, nil] Created opportunity or nil
-
def create_opportunity_from_email(synced_email)
-
return nil if synced_email.opportunity.present?
-
-
detector = Gmail::OpportunityDetectorService.new(synced_email)
-
opportunity = detector.create_opportunity!
-
-
# Queue background job for AI extraction
-
ProcessOpportunityEmailJob.perform_later(opportunity.id)
-
-
opportunity
-
rescue StandardError => e
-
Rails.logger.error "Failed to create opportunity from email #{synced_email.id}: #{e.class} - #{e.message}"
-
notify_error(
-
e,
-
context: "gmail_sync",
-
severity: "error",
-
user: user,
-
operation: "create_opportunity",
-
synced_email_id: synced_email.id
-
)
-
nil
-
end
-
end
-
# frozen_string_literal: true
-
-
module InterviewPrep
-
# Base class for LLM-backed prep generation with provider fallback and logging.
-
class BaseGeneratorService < ApplicationService
-
# @param user [User]
-
# @param interview_application [InterviewApplication]
-
def initialize(user:, interview_application:)
-
@user = user
-
@application = interview_application
-
@inputs_builder = InterviewPrep::InputsBuilderService.new(user: user, interview_application: interview_application)
-
end
-
-
# Generates and persists the artifact for this generator's kind.
-
#
-
# @return [InterviewPrepArtifact]
-
def call
-
artifact = find_or_build_artifact
-
digest = inputs_builder.digest_for(kind)
-
-
if artifact.status == "computed" && artifact.inputs_digest == digest
-
return artifact
-
end
-
-
artifact.assign_attributes(status: :pending, inputs_digest: digest, error_message: nil)
-
artifact.save!
-
-
inputs = inputs_builder.build
-
prompt = build_prompt(inputs)
-
result = run_with_providers(prompt)
-
-
if result[:success]
-
artifact.assign_attributes(
-
status: :computed,
-
computed_at: Time.current,
-
content: result[:content],
-
provider: result[:provider],
-
model: result[:model],
-
llm_api_log_id: result[:llm_api_log_id]
-
)
-
else
-
artifact.assign_attributes(
-
status: :failed,
-
computed_at: Time.current,
-
error_message: result[:error].to_s,
-
content: {}
-
)
-
end
-
-
artifact.save!
-
artifact
-
end
-
-
private
-
-
attr_reader :user, :application, :inputs_builder
-
-
# @return [Symbol]
-
def kind
-
raise NotImplementedError, "#{self.class} must implement #kind"
-
end
-
-
# @return [Ai::LlmPrompt, nil]
-
def prompt_class
-
raise NotImplementedError, "#{self.class} must implement #prompt_class"
-
end
-
-
# @return [String] operation_type for Ai::ApiLoggerService
-
def operation_type
-
"interview_prep_#{kind}"
-
end
-
-
def find_or_build_artifact
-
InterviewPrepArtifact.find_or_initialize_by(interview_application: application, kind: kind).tap do |a|
-
a.user ||= user
-
end
-
end
-
-
def build_prompt(inputs)
-
vars = {
-
candidate_profile: JSON.generate(inputs[:candidate_profile]),
-
job_context: JSON.generate(inputs[:job_context]),
-
interview_stage: inputs[:interview_stage].to_s,
-
feedback_themes: JSON.generate(inputs[:feedback_themes])
-
}
-
-
Ai::PromptBuilderService.new(
-
prompt_class: prompt_class,
-
variables: vars
-
).run
-
end
-
-
def provider_chain
-
LlmProviders::ProviderConfigHelper.all_providers
-
end
-
-
def run_with_providers(prompt)
-
template_record = prompt_class.active_prompt
-
system_message =
-
template_record&.system_prompt.presence ||
-
(prompt_class.respond_to?(:default_system_prompt) ? prompt_class.default_system_prompt : nil)
-
-
runner = Ai::ProviderRunnerService.new(
-
provider_chain: provider_chain,
-
prompt: prompt,
-
content_size: prompt.bytesize,
-
system_message: system_message,
-
provider_for: method(:provider_for),
-
logger_builder: lambda { |provider_name, provider|
-
Ai::ApiLoggerService.new(
-
operation_type: operation_type,
-
loggable: application,
-
provider: provider_name,
-
model: provider.respond_to?(:model_name) ? provider.model_name : "unknown",
-
llm_prompt: template_record
-
)
-
},
-
operation: operation_type,
-
loggable: application,
-
user: user,
-
error_context: {
-
severity: "warning",
-
application_id: application&.id
-
}
-
)
-
-
result = runner.run do |response|
-
parsed = parse_json(response[:content])
-
normalized = normalize_parsed(parsed)
-
log_data = normalized.merge(
-
extracted_fields: normalized.keys.map(&:to_s)
-
)
-
[ normalized, log_data, true ]
-
end
-
-
return { success: false, error: result[:error] } unless result[:success]
-
-
{
-
success: true,
-
content: result[:parsed],
-
provider: result[:provider],
-
model: result[:model],
-
llm_api_log_id: result[:llm_api_log_id]
-
}
-
end
-
-
def provider_for(provider_name)
-
case provider_name.to_s.downcase
-
when "openai" then LlmProviders::OpenaiProvider.new
-
when "anthropic" then LlmProviders::AnthropicProvider.new
-
when "ollama" then LlmProviders::OllamaProvider.new
-
else
-
raise ArgumentError, "Unknown provider: #{provider_name}"
-
end
-
end
-
-
def parse_json(text)
-
parsed = Ai::ResponseParserService.new(text).parse
-
raise "No JSON found" unless parsed
-
-
parsed
-
end
-
-
# Subclasses can override to enforce schema/shape.
-
def normalize_parsed(parsed)
-
parsed.is_a?(Hash) ? parsed : {}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module InterviewPrep
-
class GenerateFocusAreasService < BaseGeneratorService
-
private
-
-
def kind
-
:focus_areas
-
end
-
-
def prompt_class
-
Ai::InterviewPrepFocusAreasPrompt
-
end
-
-
def normalize_parsed(parsed)
-
items = Array(parsed.is_a?(Hash) ? parsed["focus_areas"] : nil)
-
focus_areas = items.map do |item|
-
next unless item.is_a?(Hash)
-
-
{
-
title: item["title"].to_s.strip,
-
why_it_matters: item["why_it_matters"].to_s.strip,
-
how_to_prepare: Array(item["how_to_prepare"]).map(&:to_s).map(&:strip).reject(&:blank?).first(8),
-
experiences_to_use: Array(item["experiences_to_use"]).map(&:to_s).map(&:strip).reject(&:blank?).first(8)
-
}.compact
-
end.compact
-
-
{ focus_areas: focus_areas.first(6) }
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module InterviewPrep
-
class GenerateMatchAnalysisService < BaseGeneratorService
-
private
-
-
def kind
-
:match_analysis
-
end
-
-
def prompt_class
-
Ai::InterviewPrepMatchPrompt
-
end
-
-
def normalize_parsed(parsed)
-
return {} unless parsed.is_a?(Hash)
-
-
{
-
match_label: parsed["match_label"].to_s,
-
strong_in: Array(parsed["strong_in"]).map(&:to_s).map(&:strip).reject(&:blank?).first(10),
-
partial_in: Array(parsed["partial_in"]).map(&:to_s).map(&:strip).reject(&:blank?).first(10),
-
missing_or_risky: Array(parsed["missing_or_risky"]).map(&:to_s).map(&:strip).reject(&:blank?).first(10),
-
notes: parsed["notes"].to_s.truncate(600)
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module InterviewPrep
-
class GenerateQuestionFramingService < BaseGeneratorService
-
private
-
-
def kind
-
:question_framing
-
end
-
-
def prompt_class
-
Ai::InterviewPrepQuestionFramingPrompt
-
end
-
-
def normalize_parsed(parsed)
-
items = Array(parsed.is_a?(Hash) ? parsed["questions"] : nil)
-
questions = items.map do |item|
-
next unless item.is_a?(Hash)
-
-
q = item["question"].to_s.strip
-
next if q.blank?
-
-
{
-
question: q,
-
framing: Array(item["framing"]).map(&:to_s).map(&:strip).reject(&:blank?).first(8),
-
outline: Array(item["outline"]).map(&:to_s).map(&:strip).reject(&:blank?).first(8),
-
pitfalls: Array(item["pitfalls"]).map(&:to_s).map(&:strip).reject(&:blank?).first(8)
-
}
-
end.compact
-
-
{ questions: questions.first(12) }
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module InterviewPrep
-
class GenerateStrengthPositioningService < BaseGeneratorService
-
private
-
-
def kind
-
:strength_positioning
-
end
-
-
def prompt_class
-
Ai::InterviewPrepStrengthPositioningPrompt
-
end
-
-
def normalize_parsed(parsed)
-
items = Array(parsed.is_a?(Hash) ? parsed["strengths"] : nil)
-
strengths = items.map do |item|
-
next unless item.is_a?(Hash)
-
-
title = item["title"].to_s.strip
-
next if title.blank?
-
-
{
-
title: title,
-
positioning: item["positioning"].to_s.strip,
-
evidence_types: Array(item["evidence_types"]).map(&:to_s).map(&:strip).reject(&:blank?).first(8)
-
}
-
end.compact
-
-
{ strengths: strengths.first(10) }
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
require "digest"
-
-
module InterviewPrep
-
# Builds normalized inputs for interview prep generation and computes digests for caching.
-
class InputsBuilderService
-
ALGORITHM_VERSION = "v1_job_listing_primary"
-
-
# @param user [User]
-
# @param interview_application [InterviewApplication]
-
def initialize(user:, interview_application:)
-
@user = user
-
@application = interview_application
-
end
-
-
# Returns a hash of inputs used by all prep generators.
-
#
-
# @return [Hash]
-
def build
-
{
-
algorithm_version: ALGORITHM_VERSION,
-
candidate_profile: candidate_profile,
-
job_context: job_context,
-
interview_stage: interview_stage,
-
feedback_themes: feedback_themes
-
}
-
end
-
-
# Computes an idempotency digest for a specific artifact kind.
-
#
-
# @param kind [String, Symbol]
-
# @return [String]
-
def digest_for(kind)
-
payload = build.merge(kind: kind.to_s)
-
Digest::SHA256.hexdigest(JSON.generate(payload))
-
end
-
-
private
-
-
attr_reader :user, :application
-
-
def candidate_profile
-
resume = user.user_resumes.analyzed.recent_first.first
-
top_skills = user.top_skills(limit: 15).includes(:skill_tag).map do |us|
-
{
-
skill: us.skill_tag&.name,
-
aggregated_level: us.aggregated_level&.round(2),
-
category: us.category
-
}.compact
-
end
-
-
{
-
user: {
-
id: user.id,
-
name: user.name,
-
years_of_experience: user.years_of_experience,
-
current_company: user.current_company&.name,
-
current_job_role: user.current_job_role&.title
-
},
-
resume: {
-
id: resume&.id,
-
analyzed_at: resume&.analyzed_at,
-
summary: resume&.analysis_summary,
-
strengths: Array(resume&.strengths),
-
domains: Array(resume&.domains)
-
},
-
top_skills: top_skills
-
}
-
end
-
-
# JobListing is the default source of truth.
-
# Manual job_description_text is supplemental fallback/extra context.
-
def job_context
-
jl = application.job_listing
-
-
extracted = if jl
-
{
-
title: jl.display_title,
-
url: jl.url,
-
location: jl.location_display,
-
salary_range: jl.salary_range,
-
description: jl.description,
-
responsibilities: jl.responsibilities,
-
requirements: jl.requirements,
-
about_company: jl.about_company,
-
company_culture: jl.company_culture,
-
benefits: jl.benefits,
-
perks: jl.perks,
-
custom_sections: jl.custom_sections
-
}.compact
-
else
-
{}
-
end
-
-
{
-
company: application.display_company&.name,
-
role: application.display_job_role&.title,
-
extracted_job_listing: extracted,
-
supplemental_job_text: application.job_description_text.presence
-
}.compact
-
end
-
-
def interview_stage
-
next_round = application.interview_rounds.upcoming.order(scheduled_at: :asc).first
-
return next_round.stage.to_s if next_round&.stage.present?
-
-
# Fallback mapping from pipeline stage
-
case application.pipeline_stage&.to_sym
-
when :screening then "screening"
-
when :interviewing then "technical"
-
when :offer then "hiring_manager"
-
else "screening"
-
end
-
end
-
-
def feedback_themes
-
feedbacks = InterviewFeedback
-
.joins(interview_round: { interview_application: :user })
-
.where(users: { id: user.id })
-
.recent
-
.limit(50)
-
-
tags = feedbacks.flat_map(&:tag_list).map(&:to_s).map(&:strip).reject(&:blank?)
-
tag_counts = tags.each_with_object(Hash.new(0)) { |t, h| h[t] += 1 }
-
-
{
-
top_tags: tag_counts.sort_by { |_k, v| -v }.first(10).map { |k, v| { tag: k, count: v } },
-
notes: "Derived from your recent self-reflections (tags only)."
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module InterviewRoundPrep
-
# Aggregates company-specific interview patterns from historical data.
-
#
-
# Analyzes interview data from all users who interviewed at the same company
-
# to identify:
-
# - Common round sequences
-
# - Typical interview formats and durations
-
# - Success factors
-
# - Question themes (anonymized)
-
#
-
# @example
-
# service = InterviewRoundPrep::CompanyPatternsService.new(
-
# company: company,
-
# round_type: round_type
-
# )
-
# patterns = service.analyze
-
class CompanyPatternsService < ApplicationService
-
# @param company [Company]
-
# @param round_type [InterviewRoundType, nil]
-
def initialize(company:, round_type:)
-
@company = company
-
@round_type = round_type
-
end
-
-
# Analyzes company interview patterns
-
#
-
# @return [Hash]
-
def analyze
-
return empty_analysis if company.nil? || company_rounds.empty?
-
-
{
-
company_name: company.name,
-
total_interviews: company_applications.count,
-
round_type_data: round_type_patterns,
-
typical_process: typical_interview_process,
-
success_indicators: success_indicators,
-
average_duration_minutes: average_duration,
-
interview_style_hints: interview_style_hints
-
}.compact
-
end
-
-
private
-
-
attr_reader :company, :round_type
-
-
# @return [ActiveRecord::Relation]
-
def company_applications
-
@company_applications ||= InterviewApplication.where(company: company)
-
end
-
-
# @return [ActiveRecord::Relation]
-
def company_rounds
-
@company_rounds ||= InterviewRound
-
.joins(:interview_application)
-
.where(interview_applications: { company_id: company.id })
-
end
-
-
# @return [ActiveRecord::Relation]
-
def type_specific_rounds
-
return company_rounds unless round_type
-
-
@type_specific_rounds ||= company_rounds.where(interview_round_type_id: round_type.id)
-
end
-
-
# Patterns specific to the round type at this company
-
#
-
# @return [Hash, nil]
-
def round_type_patterns
-
return nil unless round_type
-
-
type_rounds = type_specific_rounds
-
return nil if type_rounds.empty?
-
-
completed = type_rounds.where.not(completed_at: nil)
-
passed = completed.where(result: :passed)
-
-
{
-
round_type_name: round_type.name,
-
total_at_company: type_rounds.count,
-
pass_rate: completed.any? ? (passed.count.to_f / completed.count * 100).round(1) : nil,
-
common_position: most_common_position(type_rounds)
-
}.compact
-
end
-
-
# Identifies the typical interview process at this company
-
#
-
# @return [Hash]
-
def typical_interview_process
-
# Count rounds per application
-
round_counts = company_applications
-
.joins(:interview_rounds)
-
.group("interview_applications.id")
-
.count("interview_rounds.id")
-
-
avg_rounds = round_counts.values.any? ? (round_counts.values.sum.to_f / round_counts.size).round(1) : nil
-
-
# Common stage sequence
-
stage_sequence = company_rounds
-
.order(:position)
-
.pluck(:stage)
-
.uniq
-
-
{
-
average_rounds: avg_rounds,
-
typical_stages: stage_sequence.first(6),
-
total_applications_analyzed: company_applications.count
-
}.compact
-
end
-
-
# Identifies success indicators from applications that received offers
-
#
-
# @return [Hash]
-
def success_indicators
-
successful_apps = company_applications.where(pipeline_stage: :offer)
-
return nil if successful_apps.empty?
-
-
successful_rounds = InterviewRound
-
.joins(:interview_application)
-
.where(interview_applications: { id: successful_apps.select(:id) })
-
.where.not(completed_at: nil)
-
-
# Common tags from successful interview feedback
-
feedbacks = InterviewFeedback
-
.joins(interview_round: :interview_application)
-
.where(interview_applications: { id: successful_apps.select(:id) })
-
-
success_tags = feedbacks.flat_map(&:tag_list).map(&:to_s).map(&:strip).reject(&:blank?)
-
tag_counts = success_tags.each_with_object(Hash.new(0)) { |t, h| h[t] += 1 }
-
top_tags = tag_counts.sort_by { |_k, v| -v }.first(5).map { |tag, _| tag }
-
-
{
-
successful_applications: successful_apps.count,
-
success_rate: (successful_apps.count.to_f / company_applications.count * 100).round(1),
-
common_success_factors: top_tags
-
}.compact
-
end
-
-
# @return [Integer, nil]
-
def average_duration
-
durations = type_specific_rounds.where.not(duration_minutes: nil).pluck(:duration_minutes)
-
return nil if durations.empty?
-
-
(durations.sum.to_f / durations.size).round
-
end
-
-
# Infers interview style from available data
-
#
-
# @return [Array<String>]
-
def interview_style_hints
-
hints = []
-
-
# Check for video links (remote interviews)
-
video_count = type_specific_rounds.where.not(video_link: nil).count
-
if video_count > type_specific_rounds.count / 2
-
hints << "Often conducted remotely"
-
end
-
-
# Check for multiple interviewer mentions
-
panel_rounds = type_specific_rounds.where("interview_rounds.notes ILIKE ?", "%panel%")
-
if panel_rounds.any?
-
hints << "May include panel interviews"
-
end
-
-
# Check typical duration patterns
-
avg_dur = average_duration
-
if avg_dur
-
if avg_dur >= 60
-
hints << "Typically extended sessions (60+ min)"
-
elsif avg_dur <= 30
-
hints << "Usually quick sessions (30 min or less)"
-
end
-
end
-
-
hints
-
end
-
-
# @param rounds [ActiveRecord::Relation]
-
# @return [Integer, nil]
-
def most_common_position(rounds)
-
positions = rounds.where.not(position: nil).pluck(:position)
-
return nil if positions.empty?
-
-
positions.group_by(&:itself).max_by { |_, v| v.size }&.first
-
end
-
-
# @return [Hash]
-
def empty_analysis
-
{
-
note: "Limited company data available",
-
total_interviews: 0
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module InterviewRoundPrep
-
# Orchestrates round-specific interview prep generation using LLM providers.
-
#
-
# Builds comprehensive prep content including:
-
# - Round summary and format hints
-
# - Expected questions tailored to round type
-
# - Historical performance analysis
-
# - Company-specific patterns
-
# - Preparation checklist
-
#
-
# @example
-
# service = InterviewRoundPrep::GenerateService.new(interview_round: round)
-
# artifact = service.call
-
class GenerateService < ApplicationService
-
# @param interview_round [InterviewRound]
-
# @param force [Boolean] Force regeneration even if artifact exists
-
def initialize(interview_round:, force: false)
-
@round = interview_round
-
@force = force
-
@inputs_builder = InputsBuilderService.new(interview_round: round)
-
end
-
-
# Generates and persists the prep artifact.
-
#
-
# @return [InterviewRoundPrepArtifact]
-
def call
-
artifact = find_or_build_artifact
-
digest = inputs_builder.digest_for(:comprehensive)
-
-
# Return cached if valid and not forcing regeneration
-
if !force && artifact.persisted? && artifact.completed? && artifact.inputs_digest == digest
-
return artifact
-
end
-
-
# Mark as generating
-
artifact.assign_attributes(status: :generating, inputs_digest: digest)
-
artifact.save!
-
-
# Build inputs and generate
-
inputs = inputs_builder.build
-
prompt = build_prompt(inputs)
-
result = run_with_providers(prompt)
-
-
if result[:success]
-
artifact.complete!(result[:content], digest: digest)
-
else
-
artifact.fail!(result[:error])
-
end
-
-
artifact
-
end
-
-
private
-
-
attr_reader :round, :force, :inputs_builder
-
-
# @return [InterviewRoundPrepArtifact]
-
def find_or_build_artifact
-
InterviewRoundPrepArtifact.find_or_initialize_for(
-
interview_round: round,
-
kind: :comprehensive
-
)
-
end
-
-
# @return [Ai::RoundPrepPrompt, nil]
-
def prompt_class
-
Ai::RoundPrepPrompt
-
end
-
-
# @return [String]
-
def operation_type
-
"round_prep_comprehensive"
-
end
-
-
# @return [String]
-
def build_prompt(inputs)
-
vars = {
-
round_context: JSON.pretty_generate(inputs[:round_context]),
-
job_context: JSON.pretty_generate(inputs[:job_context]),
-
candidate_profile: JSON.pretty_generate(inputs[:candidate_profile]),
-
historical_performance: JSON.pretty_generate(inputs[:historical_performance]),
-
company_patterns: JSON.pretty_generate(inputs[:company_patterns])
-
}
-
-
Ai::PromptBuilderService.new(
-
prompt_class: prompt_class,
-
variables: vars
-
).run
-
end
-
-
# @return [Array<String>]
-
def provider_chain
-
LlmProviders::ProviderConfigHelper.all_providers
-
end
-
-
# @return [Hash]
-
def run_with_providers(prompt)
-
template_record = prompt_class.active_prompt
-
system_message = template_record&.system_prompt.presence ||
-
(prompt_class.respond_to?(:default_system_prompt) ? prompt_class.default_system_prompt : nil)
-
-
runner = Ai::ProviderRunnerService.new(
-
provider_chain: provider_chain,
-
prompt: prompt,
-
content_size: prompt.bytesize,
-
system_message: system_message,
-
provider_for: method(:provider_for),
-
logger_builder: lambda { |provider_name, provider|
-
Ai::ApiLoggerService.new(
-
operation_type: operation_type,
-
loggable: round,
-
provider: provider_name,
-
model: provider.respond_to?(:model_name) ? provider.model_name : "unknown",
-
llm_prompt: template_record
-
)
-
},
-
operation: operation_type,
-
loggable: round,
-
user: round.interview_application&.user,
-
error_context: {
-
severity: "warning",
-
interview_round_id: round&.id
-
}
-
)
-
-
result = runner.run do |response|
-
parsed = parse_json(response[:content])
-
normalized = normalize_content(parsed)
-
[ normalized, normalized, true ]
-
end
-
-
return { success: false, error: result[:error] } unless result[:success]
-
-
{
-
success: true,
-
content: result[:parsed],
-
provider: result[:provider],
-
model: result[:model]
-
}
-
end
-
-
# @return [LlmProviders::BaseProvider]
-
def provider_for(provider_name)
-
case provider_name.to_s.downcase
-
when "openai" then LlmProviders::OpenaiProvider.new
-
when "anthropic" then LlmProviders::AnthropicProvider.new
-
when "ollama" then LlmProviders::OllamaProvider.new
-
else
-
raise ArgumentError, "Unknown provider: #{provider_name}"
-
end
-
end
-
-
# @return [Hash]
-
def parse_json(text)
-
parsed = Ai::ResponseParserService.new(text).parse
-
raise "No JSON found in response" unless parsed
-
-
parsed
-
end
-
-
# Normalizes the parsed content to expected schema
-
#
-
# @return [Hash]
-
def normalize_content(parsed)
-
return {} unless parsed.is_a?(Hash)
-
-
{
-
round_summary: parsed["round_summary"],
-
expected_questions: Array(parsed["expected_questions"]),
-
your_history: parsed["your_history"],
-
company_patterns: parsed["company_patterns"],
-
preparation_checklist: Array(parsed["preparation_checklist"]),
-
answer_strategies: Array(parsed["answer_strategies"]),
-
tips: Array(parsed["tips"])
-
}.compact
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module InterviewRoundPrep
-
# Analyzes user's historical performance on similar interview round types.
-
#
-
# Examines past rounds to identify:
-
# - Pass rate for this type of interview
-
# - Common feedback themes (strengths and areas to improve)
-
# - Time patterns (average duration, recent trends)
-
#
-
# @example
-
# service = InterviewRoundPrep::HistoricalAnalyzerService.new(
-
# user: user,
-
# round_type: round_type
-
# )
-
# analysis = service.analyze
-
class HistoricalAnalyzerService < ApplicationService
-
# @param user [User]
-
# @param round_type [InterviewRoundType, nil]
-
def initialize(user:, round_type:)
-
@user = user
-
@round_type = round_type
-
end
-
-
# Analyzes historical performance
-
#
-
# @return [Hash]
-
def analyze
-
return empty_analysis if past_rounds.empty?
-
-
{
-
total_rounds: past_rounds.count,
-
completed_rounds: completed_rounds.count,
-
pass_rate: calculate_pass_rate,
-
performance_trend: performance_trend,
-
feedback_themes: feedback_themes,
-
common_strengths: common_strengths,
-
areas_to_improve: areas_to_improve,
-
average_duration_minutes: average_duration
-
}.compact
-
end
-
-
private
-
-
attr_reader :user, :round_type
-
-
# @return [ActiveRecord::Relation]
-
def past_rounds
-
@past_rounds ||= begin
-
scope = InterviewRound
-
.joins(interview_application: :user)
-
.where(interview_applications: { user_id: user.id })
-
-
if round_type
-
scope = scope.where(interview_round_type_id: round_type.id)
-
end
-
-
scope.order(created_at: :desc).limit(50)
-
end
-
end
-
-
# @return [ActiveRecord::Relation]
-
def completed_rounds
-
@completed_rounds ||= past_rounds.where.not(completed_at: nil)
-
end
-
-
# @return [Float, nil]
-
def calculate_pass_rate
-
return nil if completed_rounds.empty?
-
-
passed = completed_rounds.where(result: :passed).count
-
total = completed_rounds.count
-
-
return nil if total.zero?
-
-
(passed.to_f / total * 100).round(1)
-
end
-
-
# Analyzes recent performance trend
-
#
-
# @return [String, nil]
-
def performance_trend
-
recent = completed_rounds.limit(5)
-
return nil if recent.count < 3
-
-
recent_results = recent.pluck(:result)
-
passed_count = recent_results.count("passed")
-
-
if passed_count >= 4
-
"strong"
-
elsif passed_count >= 3
-
"positive"
-
elsif passed_count >= 2
-
"mixed"
-
else
-
"needs_improvement"
-
end
-
end
-
-
# Extracts feedback themes from past round feedback
-
#
-
# @return [Array<Hash>]
-
def feedback_themes
-
feedbacks = InterviewFeedback
-
.joins(interview_round: :interview_application)
-
.where(interview_applications: { user_id: user.id })
-
-
if round_type
-
feedbacks = feedbacks.joins(:interview_round)
-
.where(interview_rounds: { interview_round_type_id: round_type.id })
-
end
-
-
feedbacks = feedbacks.order(created_at: :desc).limit(20)
-
-
# Extract tags and count frequencies
-
tags = feedbacks.flat_map(&:tag_list).map(&:to_s).map(&:strip).reject(&:blank?)
-
tag_counts = tags.each_with_object(Hash.new(0)) { |t, h| h[t] += 1 }
-
-
tag_counts.sort_by { |_k, v| -v }.first(10).map do |tag, count|
-
{ tag: tag, count: count }
-
end
-
end
-
-
# Identifies common strengths from feedback
-
#
-
# @return [Array<String>]
-
def common_strengths
-
# Look for positive patterns in feedback went_well field
-
feedbacks = InterviewFeedback
-
.joins(interview_round: :interview_application)
-
.where(interview_applications: { user_id: user.id })
-
.where.not(went_well: [ nil, "" ])
-
-
if round_type
-
feedbacks = feedbacks.joins(:interview_round)
-
.where(interview_rounds: { interview_round_type_id: round_type.id })
-
end
-
-
# Extract from tags that indicate strengths
-
strength_tags = feedbacks.flat_map(&:tag_list)
-
.map(&:to_s)
-
.select { |t| t.match?(/strong|good|excellent|clear|effective/i) }
-
-
strength_tags.uniq.first(5)
-
end
-
-
# Identifies areas that need improvement
-
#
-
# @return [Array<String>]
-
def areas_to_improve
-
# Look for patterns in to_improve field
-
feedbacks = InterviewFeedback
-
.joins(interview_round: :interview_application)
-
.where(interview_applications: { user_id: user.id })
-
.where.not(to_improve: [ nil, "" ])
-
-
if round_type
-
feedbacks = feedbacks.joins(:interview_round)
-
.where(interview_rounds: { interview_round_type_id: round_type.id })
-
end
-
-
# Extract from tags that indicate improvement areas
-
improvement_tags = feedbacks.flat_map(&:tag_list)
-
.map(&:to_s)
-
.reject { |t| t.match?(/strong|good|excellent/i) }
-
-
improvement_tags.uniq.first(5)
-
end
-
-
# @return [Integer, nil]
-
def average_duration
-
durations = completed_rounds.where.not(duration_minutes: nil).pluck(:duration_minutes)
-
return nil if durations.empty?
-
-
(durations.sum.to_f / durations.size).round
-
end
-
-
# @return [Hash]
-
def empty_analysis
-
{
-
total_rounds: 0,
-
completed_rounds: 0,
-
note: "No historical data available for this round type"
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
require "digest"
-
-
module InterviewRoundPrep
-
# Builds normalized inputs for round-specific interview prep generation.
-
#
-
# Gathers context from:
-
# - The interview round (type, stage, scheduled time, interviewer)
-
# - The interview application (company, job role, job listing)
-
# - User profile and history
-
# - Historical performance on similar round types
-
# - Company interview patterns
-
#
-
# @example
-
# service = InterviewRoundPrep::InputsBuilderService.new(interview_round: round)
-
# inputs = service.build
-
# digest = service.digest_for(:comprehensive)
-
class InputsBuilderService < ApplicationService
-
ALGORITHM_VERSION = "v1_round_prep"
-
-
# @param interview_round [InterviewRound]
-
def initialize(interview_round:)
-
@round = interview_round
-
@application = interview_round.interview_application
-
@user = @application.user
-
end
-
-
# Returns a hash of inputs used by round prep generators.
-
#
-
# @return [Hash]
-
def build
-
{
-
algorithm_version: ALGORITHM_VERSION,
-
round_context: round_context,
-
job_context: job_context,
-
candidate_profile: candidate_profile,
-
historical_performance: historical_performance,
-
company_patterns: company_patterns
-
}
-
end
-
-
# Computes an idempotency digest for a specific artifact kind.
-
#
-
# @param kind [String, Symbol]
-
# @return [String]
-
def digest_for(kind)
-
payload = build.merge(kind: kind.to_s)
-
Digest::SHA256.hexdigest(JSON.generate(payload))
-
end
-
-
private
-
-
attr_reader :round, :application, :user
-
-
# Context about the specific interview round
-
#
-
# @return [Hash]
-
def round_context
-
{
-
id: round.id,
-
stage: round.stage,
-
stage_name: round.stage_display_name,
-
round_type: round_type_context,
-
scheduled_at: round.scheduled_at&.iso8601,
-
duration_minutes: round.duration_minutes,
-
interviewer: {
-
name: round.interviewer_name,
-
role: round.interviewer_role
-
}.compact.presence,
-
notes: round.notes,
-
position_in_process: round.position,
-
has_video_link: round.has_video_link?
-
}.compact
-
end
-
-
# Round type information
-
#
-
# @return [Hash, nil]
-
def round_type_context
-
return nil unless round.interview_round_type
-
-
{
-
name: round.interview_round_type.name,
-
slug: round.interview_round_type.slug,
-
description: round.interview_round_type.description,
-
department: round.interview_round_type.department_name
-
}.compact
-
end
-
-
# Context about the job application
-
#
-
# @return [Hash]
-
def job_context
-
jl = application.job_listing
-
-
extracted = if jl
-
{
-
title: jl.display_title,
-
url: jl.url,
-
location: jl.location_display,
-
salary_range: jl.salary_range,
-
description: jl.description,
-
responsibilities: jl.responsibilities,
-
requirements: jl.requirements,
-
about_company: jl.about_company,
-
company_culture: jl.company_culture,
-
custom_sections: jl.custom_sections
-
}.compact
-
else
-
{}
-
end
-
-
{
-
company: application.display_company&.name,
-
company_id: application.company_id,
-
role: application.display_job_role&.title,
-
role_id: application.job_role_id,
-
department: application.job_role&.department_name,
-
extracted_job_listing: extracted.presence,
-
supplemental_job_text: application.job_description_text.presence,
-
pipeline_stage: application.pipeline_stage
-
}.compact
-
end
-
-
# Candidate profile information
-
#
-
# @return [Hash]
-
def candidate_profile
-
resume = user.user_resumes.analyzed.recent_first.first
-
top_skills = user.top_skills(limit: 10).includes(:skill_tag).map do |us|
-
{
-
skill: us.skill_tag&.name,
-
level: us.aggregated_level&.round(2)
-
}.compact
-
end
-
-
{
-
name: user.name,
-
years_of_experience: user.years_of_experience,
-
current_role: user.current_job_role&.title,
-
current_company: user.current_company&.name,
-
resume_summary: resume&.analysis_summary,
-
strengths: Array(resume&.strengths).first(5),
-
top_skills: top_skills
-
}.compact
-
end
-
-
# Historical performance on similar round types
-
#
-
# @return [Hash]
-
def historical_performance
-
safely(fallback: { note: "Historical data unavailable", error: true }) do
-
HistoricalAnalyzerService.new(user: user, round_type: round.interview_round_type).analyze
-
end
-
end
-
-
# Company-specific interview patterns
-
#
-
# @return [Hash]
-
def company_patterns
-
safely(fallback: { note: "Company pattern data unavailable", error: true }) do
-
CompanyPatternsService.new(
-
company: application.company,
-
round_type: round.interview_round_type
-
).analyze
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# Service for scraping job listing information from URLs
-
#
-
# This service delegates to the Scraping::OrchestratorService for actual extraction.
-
# It maintains backward compatibility with the existing interface while using
-
# the new orchestration system under the hood.
-
#
-
# @example
-
# service = JobListingScraperService.new(url: "https://company.com/jobs/123")
-
# result = service.scrape
-
# job_listing.update(scraped_data: result)
-
#
-
class JobListingScraperService
-
# Initialize the service with a job listing
-
#
-
# @param [String] url The URL of the job listing (deprecated - use job_listing)
-
# @param [JobListing] job_listing The job listing model to extract for
-
def initialize(url: nil, job_listing: nil)
-
if job_listing
-
@job_listing = job_listing
-
@url = job_listing.url
-
elsif url
-
# For backward compatibility - create a temporary job listing
-
@url = url
-
@job_listing = nil
-
else
-
raise ArgumentError, "Must provide either url or job_listing"
-
end
-
-
@scraped_at = Time.current
-
end
-
-
# Scrapes the job listing using the orchestration service
-
#
-
# @return [Hash] Scraped data including title, description, requirements, etc.
-
def scrape
-
# If we have a job listing model, use the orchestrator
-
if @job_listing
-
orchestrator = Scraping::OrchestratorService.new(@job_listing)
-
success = orchestrator.call
-
-
# Return scraped data format
-
if success
-
{
-
scraped_at: @scraped_at.iso8601,
-
source_url: @url,
-
title: @job_listing.title,
-
description: @job_listing.description,
-
requirements: @job_listing.requirements,
-
responsibilities: @job_listing.responsibilities,
-
location: @job_listing.location,
-
remote_type: @job_listing.remote_type,
-
salary_min: @job_listing.salary_min,
-
salary_max: @job_listing.salary_max,
-
salary_currency: @job_listing.salary_currency,
-
equity_info: @job_listing.equity_info,
-
benefits: @job_listing.benefits,
-
perks: @job_listing.perks,
-
custom_sections: @job_listing.custom_sections,
-
success: true,
-
error: nil
-
}
-
else
-
{
-
scraped_at: @scraped_at.iso8601,
-
source_url: @url,
-
success: false,
-
error: "Extraction failed - check scraping attempts for details"
-
}
-
end
-
else
-
# Fallback for URL-only usage (not recommended)
-
Rails.logger.warn("JobListingScraperService called without job_listing model")
-
{
-
scraped_at: @scraped_at.iso8601,
-
source_url: @url,
-
success: false,
-
error: "Job listing model required for extraction"
-
}
-
end
-
end
-
-
# Checks if the URL is scrapable
-
#
-
# @return [Boolean] True if URL can be scraped
-
def scrapable?
-
return false if @url.blank?
-
-
# Check if URL is from supported job boards
-
supported_domains.any? { |domain| @url.include?(domain) }
-
end
-
-
# Returns list of supported job board domains
-
#
-
# @return [Array<String>] List of supported domains
-
def supported_domains
-
[
-
"linkedin.com",
-
"indeed.com",
-
"glassdoor.com",
-
"greenhouse.io",
-
"lever.co",
-
"workable.com",
-
"jobvite.com",
-
"icims.com",
-
"smartrecruiters.com",
-
"bamboohr.com",
-
"ashbyhq.com"
-
]
-
end
-
end
-
# frozen_string_literal: true
-
-
module JobListings
-
# Service for finding or creating a JobListing from a URL.
-
#
-
# Normalizes the URL (removes common tracking params), and ensures the JobListing
-
# has the required associations (company, job_role).
-
#
-
# This service intentionally does NOT enqueue scraping jobs; scraping is handled
-
# by higher-level workflow orchestration (Signals decision execution, opportunity apply, etc.).
-
class UpsertFromUrlService
-
# @param url [String]
-
# @param company [Company]
-
# @param job_role [JobRole]
-
# @param title [String, nil]
-
def initialize(url:, company:, job_role:, title: nil)
-
@url = url
-
@company = company
-
@job_role = job_role
-
@title = title
-
end
-
-
# @return [Hash] { job_listing: JobListing, created: Boolean, normalized_url: String }
-
def call
-
raise ArgumentError, "url is required" if url.blank?
-
raise ArgumentError, "company is required" if company.blank?
-
raise ArgumentError, "job_role is required" if job_role.blank?
-
-
normalized_url = normalize_url(url)
-
existing = JobListing.find_by(url: normalized_url)
-
return { job_listing: existing, created: false, normalized_url: normalized_url } if existing
-
-
base_url = normalized_url.split("?").first
-
if base_url.present? && base_url != normalized_url
-
existing_base = JobListing.find_by(url: base_url)
-
return { job_listing: existing_base, created: false, normalized_url: base_url } if existing_base
-
end
-
-
jl = JobListing.create!(
-
url: normalized_url,
-
company: company,
-
job_role: job_role,
-
title: title.presence || job_role.title,
-
status: :active,
-
source_id: extract_source_id(normalized_url)
-
)
-
{ job_listing: jl, created: true, normalized_url: normalized_url }
-
end
-
-
private
-
-
attr_reader :url, :company, :job_role, :title
-
-
def extract_source_id(url)
-
match = url.match(%r{/(jobs?|careers?|positions?)/([^/\?]+)})
-
match ? match[2] : nil
-
end
-
-
def normalize_url(url)
-
uri = URI.parse(url.strip)
-
return url.strip unless uri.query.present?
-
-
params = URI.decode_www_form(uri.query).reject do |key, _|
-
%w[utm_source utm_medium utm_campaign utm_content utm_term ref source].include?(key.downcase)
-
end
-
uri.query = params.any? ? URI.encode_www_form(params) : nil
-
uri.to_s
-
rescue URI::InvalidURIError
-
url.strip
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# Service for de-duplicating "label" style lists (e.g., strengths, domains) where the
-
# source may contain near-duplicates (punctuation/wording differences).
-
#
-
# We intentionally keep this conservative: it groups items only when they have
-
# very high token overlap, so we don't accidentally merge distinct concepts.
-
#
-
# @example
-
# groups = Labels::DedupeService.new(labels, similarity_threshold: 0.82).grouped_counts
-
# # => { "ruby rails backend" => { label: "Ruby on Rails backend development", count: 2 } }
-
#
-
class Labels::DedupeService
-
# @param labels [Array<String>]
-
# @param similarity_threshold [Float] Jaccard similarity threshold for grouping
-
# @param overlap_threshold [Float] overlap/min_size threshold (helps group "A + one word" variants)
-
def initialize(labels, similarity_threshold: 0.85, overlap_threshold: 0.75)
-
@labels = Array(labels)
-
@similarity_threshold = similarity_threshold.to_f
-
@overlap_threshold = overlap_threshold.to_f
-
end
-
-
# Returns labels de-duplicated into representative strings.
-
#
-
# @return [Array<String>]
-
def run
-
grouped_counts.values.map { |h| h[:label] }
-
end
-
-
# Returns grouped counts keyed by a normalized key.
-
#
-
# @return [Hash{String => Hash}] e.g. { "system design" => { label: "System Design", count: 2 } }
-
def grouped_counts
-
groups = []
-
-
normalized_labels.each do |label|
-
tokens = tokens_for(label)
-
next if tokens.empty?
-
-
best = best_group_for(groups, tokens)
-
if best
-
best[:count] += 1
-
best[:candidates] << label
-
best[:label] = pick_representative(best[:candidates])
-
next
-
end
-
-
groups << {
-
key: normalize_key(label),
-
tokens: tokens,
-
candidates: [label],
-
label: label,
-
count: 1
-
}
-
end
-
-
groups.each_with_object({}) do |g, acc|
-
acc[g[:key]] = { label: g[:label], count: g[:count] }
-
end
-
end
-
-
private
-
-
attr_reader :labels, :similarity_threshold, :overlap_threshold
-
-
def normalized_labels
-
labels.map { |l| l.to_s.strip }.reject(&:blank?)
-
end
-
-
# Produces a stable normalized key for a label (for exact-ish matching).
-
#
-
# @param label [String]
-
# @return [String]
-
def normalize_key(label)
-
s = ActiveSupport::Inflector.transliterate(label.to_s)
-
s = s.downcase
-
s = s.tr("&", "and")
-
s = s.gsub(/[^a-z0-9\s]/, " ")
-
s = s.gsub(/\s+/, " ").strip
-
s
-
end
-
-
STOPWORDS = %w[
-
and or the a an to of for in on with across over into at from by
-
].freeze
-
-
# @param label [String]
-
# @return [Array<String>]
-
def tokens_for(label)
-
normalize_key(label).split(" ").reject { |t| t.blank? || STOPWORDS.include?(t) }.uniq
-
end
-
-
def best_group_for(groups, tokens)
-
best = nil
-
best_score = 0.0
-
-
groups.each do |g|
-
score = similarity(g[:tokens], tokens)
-
overlap = overlap_ratio(g[:tokens], tokens)
-
-
matches = score >= similarity_threshold || (overlap >= overlap_threshold && intersection_size(g[:tokens], tokens) >= 3)
-
next unless matches
-
-
if score > best_score
-
best_score = score
-
best = g
-
end
-
end
-
-
best
-
end
-
-
def intersection_size(a, b)
-
(a & b).size
-
end
-
-
def similarity(a, b)
-
return 0.0 if a.empty? || b.empty?
-
-
inter = intersection_size(a, b)
-
union = (a | b).size
-
union.positive? ? (inter.to_f / union) : 0.0
-
end
-
-
def overlap_ratio(a, b)
-
return 0.0 if a.empty? || b.empty?
-
-
inter = intersection_size(a, b)
-
min_size = [a.size, b.size].min
-
min_size.positive? ? (inter.to_f / min_size) : 0.0
-
end
-
-
def pick_representative(candidates)
-
# Prefer the shortest non-trivial label (usually more readable).
-
candidates
-
.map { |c| c.to_s.strip }
-
.reject(&:blank?)
-
.min_by { |c| [c.length, c] }
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module LlmProviders
-
# Anthropic Claude provider for LLM completions
-
#
-
# Uses the Anthropic Ruby SDK with streaming for efficient long-running requests.
-
# Includes rate limiting via AnthropicRateLimiterService.
-
#
-
# Supports multimodal input:
-
# - Images: JPEG, PNG, GIF, WebP
-
# - Documents: PDF (native), DOCX (via text extraction)
-
class AnthropicProvider < BaseProvider
-
# Supported image MIME types
-
SUPPORTED_IMAGE_TYPES = %w[
-
image/jpeg
-
image/png
-
image/gif
-
image/webp
-
].freeze
-
-
# Natively supported document MIME types (sent as-is)
-
NATIVE_DOCUMENT_TYPES = %w[
-
application/pdf
-
].freeze
-
-
# Document types requiring text extraction before sending
-
TEXT_EXTRACTION_TYPES = %w[
-
application/vnd.openxmlformats-officedocument.wordprocessingml.document
-
].freeze
-
-
# All supported document MIME types
-
SUPPORTED_DOCUMENT_TYPES = (NATIVE_DOCUMENT_TYPES + TEXT_EXTRACTION_TYPES).freeze
-
-
# All supported media types
-
SUPPORTED_MEDIA_TYPES = (SUPPORTED_IMAGE_TYPES + SUPPORTED_DOCUMENT_TYPES).freeze
-
-
# Sends a prompt to Claude and returns the response
-
#
-
# @param prompt [String] The prompt text
-
# @param options [Hash] Optional parameters
-
# @option options [Integer] :max_tokens Maximum tokens in response
-
# @option options [Float] :temperature Temperature setting
-
# @option options [String] :system_message Optional system message
-
# @option options [Array<Hash>] :media Array of media attachments (images, documents)
-
# @return [Hash] Result with content and metadata
-
def run(prompt, options = {})
-
return rate_limit_error_response if prompt.present? && exceeds_rate_limit?(prompt)
-
-
result, latency_ms = with_timing { call_api(prompt, options) }
-
build_response(result, latency_ms)
-
rescue => e
-
handle_error(e)
-
end
-
-
# @return [Boolean] True - Anthropic Claude supports multimodal input
-
def supports_media?
-
true
-
end
-
-
# @return [Array<String>] Supported MIME types for media attachments
-
def supported_media_types
-
SUPPORTED_MEDIA_TYPES
-
end
-
-
protected
-
-
def api_key
-
Rails.application.credentials.dig(:anthropic, :api_key)
-
end
-
-
def default_model
-
"claude-sonnet-4-20250514"
-
end
-
-
private
-
-
# Checks rate limit and waits or returns error
-
def exceeds_rate_limit?(prompt)
-
estimated_tokens = estimate_tokens(prompt.to_s)
-
-
unless rate_limiter.can_send_tokens?(estimated_tokens)
-
wait_time = rate_limiter.wait_time_for_tokens(estimated_tokens)
-
if wait_time > 0
-
Rails.logger.warn("Anthropic rate limit: waiting #{wait_time}s")
-
sleep(wait_time)
-
return false
-
end
-
@rate_limit_tokens = estimated_tokens
-
return true
-
end
-
false
-
end
-
-
def rate_limit_error_response
-
error_response(
-
error: "Request would exceed token rate limit",
-
latency_ms: 0,
-
error_type: "rate_limit",
-
rate_limit: true
-
)
-
end
-
-
# Makes the actual API call
-
def call_api(prompt, options)
-
@last_provider_request = build_params(prompt, options)
-
@last_provider_endpoint =
-
if Setting.helicone_enabled?
-
Rails.application.credentials.dig(:helicone, :base_url)
-
else
-
nil
-
end
-
-
if Setting.helicone_enabled?
-
client = Anthropic::Client.new(
-
api_key: Rails.application.credentials.dig(:helicone, :api_key),
-
base_url: Rails.application.credentials.dig(:helicone, :base_url)
-
)
-
else
-
client = Anthropic::Client.new(api_key: api_key)
-
end
-
stream = client.messages.stream(**@last_provider_request)
-
-
message = stream.accumulated_message
-
message_hash = message.respond_to?(:to_h) ? message.to_h : message
-
parsed = parse_message(message)
-
# Use SDK-provided text accumulator as the most reliable source of assistant text.
-
content = stream.accumulated_text.to_s
-
content = parsed[:content] if content.blank?
-
-
record_token_usage(message)
-
-
message_id = message&.id.to_s
-
message_id = "unknown" if message_id.blank?
-
parsed = {
-
raw_response: message_hash,
-
content: content,
-
tool_calls: parsed[:tool_calls],
-
content_blocks: parsed[:content_blocks],
-
message_id: message_id,
-
provider_request: @last_provider_request,
-
provider_response: message_hash.is_a?(Hash) ? message_hash : message_hash.to_s,
-
provider_endpoint: @last_provider_endpoint,
-
input_tokens: message&.usage&.input_tokens,
-
output_tokens: message&.usage&.output_tokens
-
}
-
-
contract = Assistant::Contracts::ProviderResultContracts::Anthropic.call(parsed)
-
unless contract.success?
-
notify_error(RuntimeError.new("Anthropic provider contract failed"), operation: "call_api", error_type: "contract_failed", contract_errors: contract.errors.to_h)
-
end
-
-
parsed
-
end
-
-
def build_params(prompt, options)
-
params = {
-
model: model_name,
-
max_tokens: options[:max_tokens] || max_tokens_config,
-
temperature: options[:temperature] || temperature_config,
-
messages: build_messages(prompt, options)
-
}
-
-
params[:system] = options[:system_message] if options[:system_message].present?
-
params[:tools] = options[:tools] if options[:tools].present?
-
params[:tool_choice] = options[:tool_choice] if options.key?(:tool_choice)
-
params
-
end
-
-
def build_messages(prompt, options)
-
# If caller provides pre-built messages, use them directly
-
if options[:messages].present?
-
messages = Array(options[:messages])
-
# Attach media to the last user message if media is provided
-
if options[:media].present?
-
return inject_media_into_messages(messages, options[:media])
-
end
-
return messages
-
end
-
-
# Build simple user message with optional media
-
content = build_content_with_media(prompt, options[:media])
-
[ { role: "user", content: content } ]
-
end
-
-
# Builds content array with text and optional media blocks
-
#
-
# @param text [String] The text content
-
# @param media [Array<Hash>, nil] Optional media attachments
-
# @return [String, Array<Hash>] String if no media, Array of content blocks otherwise
-
def build_content_with_media(text, media)
-
return text.to_s if media.blank?
-
-
content_blocks = []
-
-
# Add media blocks first (images/documents)
-
Array(media).each do |m|
-
block = build_media_block(m)
-
content_blocks << block if block
-
end
-
-
# Add text block
-
content_blocks << { type: "text", text: text.to_s } if text.present?
-
-
content_blocks
-
end
-
-
# Builds a single media content block for Anthropic's API
-
#
-
# @param media [Hash] Media attachment info
-
# - :type [String] "image" or "document"
-
# - :source_type [String] "base64" or "url"
-
# - :media_type [String] MIME type
-
# - :data [String] Base64 data (if source_type is "base64")
-
# - :url [String] URL (if source_type is "url")
-
# @return [Hash, nil] Content block for Anthropic API or nil if invalid
-
def build_media_block(media)
-
media = media.symbolize_keys
-
media_type = media[:media_type].to_s
-
-
return nil unless SUPPORTED_MEDIA_TYPES.include?(media_type)
-
-
if media[:type].to_s == "document" || SUPPORTED_DOCUMENT_TYPES.include?(media_type)
-
build_document_block(media)
-
else
-
build_image_block(media)
-
end
-
end
-
-
# Builds an image content block
-
def build_image_block(media)
-
source = if media[:source_type].to_s == "url" && media[:url].present?
-
{ type: "url", url: media[:url] }
-
elsif media[:data].present?
-
{ type: "base64", media_type: media[:media_type], data: media[:data] }
-
end
-
-
return nil unless source
-
-
{ type: "image", source: source }
-
end
-
-
# Builds a document content block (PDF native, DOCX via text extraction)
-
def build_document_block(media)
-
media_type = media[:media_type].to_s
-
-
# DOCX requires text extraction since Claude doesn't natively support it
-
if TEXT_EXTRACTION_TYPES.include?(media_type)
-
return build_text_block_from_document(media)
-
end
-
-
# Native document support (PDF)
-
source = if media[:source_type].to_s == "url" && media[:url].present?
-
{ type: "url", url: media[:url] }
-
elsif media[:data].present?
-
{ type: "base64", media_type: media[:media_type], data: media[:data] }
-
end
-
-
return nil unless source
-
-
{
-
type: "document",
-
source: source,
-
cache_control: media[:cache_control] # Optional caching hint
-
}.compact
-
end
-
-
# Extracts text from a DOCX file and returns it as a text block
-
#
-
# @param media [Hash] Media attachment with :data (base64) or :text (pre-extracted)
-
# @return [Hash, nil] Text content block or nil
-
def build_text_block_from_document(media)
-
# If text was pre-extracted, use it directly
-
if media[:extracted_text].present?
-
return { type: "text", text: format_document_text(media[:extracted_text], media[:filename]) }
-
end
-
-
# If we have base64 data, try to extract text from DOCX
-
if media[:data].present? && media[:media_type] == "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
-
extracted = extract_text_from_docx(media[:data])
-
if extracted.present?
-
return { type: "text", text: format_document_text(extracted, media[:filename]) }
-
end
-
end
-
-
nil
-
end
-
-
# Extracts text from a base64-encoded DOCX file
-
#
-
# @param base64_data [String] Base64-encoded DOCX content
-
# @return [String, nil] Extracted text or nil if extraction fails
-
def extract_text_from_docx(base64_data)
-
require "docx"
-
require "base64"
-
require "tempfile"
-
-
Tempfile.create([ "document", ".docx" ]) do |temp|
-
temp.binmode
-
temp.write(Base64.decode64(base64_data))
-
temp.rewind
-
-
doc = Docx::Document.open(temp.path)
-
paragraphs = doc.paragraphs.map(&:text).reject(&:blank?)
-
paragraphs.join("\n\n")
-
end
-
rescue LoadError => e
-
Rails.logger.warn("DOCX extraction unavailable: #{e.message}. Install 'docx' gem for DOCX support.")
-
nil
-
rescue => e
-
Rails.logger.error("Failed to extract text from DOCX: #{e.message}")
-
nil
-
end
-
-
# Formats extracted document text with context
-
def format_document_text(text, filename = nil)
-
header = filename.present? ? "--- Document: #{filename} ---\n\n" : "--- Document Content ---\n\n"
-
"#{header}#{text}\n\n--- End of Document ---"
-
end
-
-
# Injects media into the last user message in a messages array
-
#
-
# @param messages [Array<Hash>] Existing messages
-
# @param media [Array<Hash>] Media to inject
-
# @return [Array<Hash>] Messages with media injected
-
def inject_media_into_messages(messages, media)
-
return messages if media.blank?
-
-
# Find the last user message
-
last_user_idx = messages.rindex { |m| m[:role] == "user" || m["role"] == "user" }
-
return messages unless last_user_idx
-
-
messages = messages.deep_dup
-
last_msg = messages[last_user_idx]
-
existing_content = last_msg[:content] || last_msg["content"]
-
-
# Convert string content to content blocks
-
if existing_content.is_a?(String)
-
new_content = build_content_with_media(existing_content, media)
-
elsif existing_content.is_a?(Array)
-
# Already an array of content blocks, prepend media
-
media_blocks = Array(media).filter_map { |m| build_media_block(m) }
-
new_content = media_blocks + existing_content
-
else
-
new_content = build_content_with_media("", media)
-
end
-
-
messages[last_user_idx] = last_msg.merge(content: new_content)
-
messages
-
end
-
-
def parse_message(message)
-
message_hash = message.respond_to?(:to_h) ? message.to_h : message
-
blocks = message_hash.is_a?(Hash) ? (message_hash["content"] || message_hash[:content]) : message&.content
-
return { content: "", tool_calls: [] } unless blocks.is_a?(Array)
-
-
text_parts = []
-
tool_calls = []
-
content_blocks = []
-
-
blocks.each do |b|
-
h =
-
if b.is_a?(Hash)
-
b
-
elsif b.respond_to?(:to_h)
-
b.to_h
-
else
-
# Best-effort for SDK objects
-
{
-
type: (b.respond_to?(:type) ? b.type : nil),
-
text: (b.respond_to?(:text) ? b.text : nil),
-
id: (b.respond_to?(:id) ? b.id : nil),
-
name: (b.respond_to?(:name) ? b.name : nil),
-
input: (b.respond_to?(:input) ? b.input : nil)
-
}.compact
-
end
-
next unless h.is_a?(Hash)
-
-
type = (h["type"] || h[:type]).to_s
-
case type
-
when "text"
-
text_parts << (h["text"] || h[:text]).to_s
-
when "output_text"
-
text_parts << (h["text"] || h[:text]).to_s
-
when "tool_use"
-
raw_input = h["input"] || h[:input] || {}
-
parsed_input =
-
if raw_input.is_a?(String)
-
begin
-
JSON.parse(raw_input)
-
rescue JSON::ParserError
-
Rails.logger.warn("[AnthropicProvider] Failed to parse tool_use.input JSON; defaulting to {} input=#{raw_input.to_s[0, 200].inspect}")
-
{}
-
end
-
else
-
raw_input
-
end
-
-
# Ensure stored content blocks match Anthropic's expected shape.
-
# Anthropic requires tool_use.input to be an object (dictionary). Some SDK versions
-
# surface it as a JSON string; normalize it here so future follow-ups can safely
-
# replay provider_content_blocks without 400s.
-
parsed_input = {} unless parsed_input.is_a?(Hash)
-
h["input"] = parsed_input if h.key?("input") || h.key?("input".to_sym)
-
h[:input] = parsed_input if h.key?(:input)
-
-
tool_calls << {
-
id: h["id"] || h[:id],
-
tool_key: h["name"] || h[:name],
-
args: parsed_input
-
}
-
else
-
# Best-effort: if this block contains a text payload, capture it.
-
text = h["text"] || h[:text]
-
text_parts << text.to_s if text.is_a?(String) && text.present?
-
end
-
-
# Store a sanitized version for safe replay during follow-ups (no SDK internals like _json_buf).
-
content_blocks << sanitize_anthropic_content_block(h)
-
end
-
-
{ content: text_parts.join, tool_calls: tool_calls, content_blocks: content_blocks }
-
end
-
-
# Anthropic is strict about content blocks: tool_use blocks cannot include extra keys.
-
# Keep only the allowed/documented fields so replays don't 400.
-
#
-
# @param h [Hash]
-
# @return [Hash]
-
def sanitize_anthropic_content_block(h)
-
type = (h["type"] || h[:type]).to_s
-
-
case type
-
when "tool_use"
-
input = h["input"] || h[:input]
-
input = {} unless input.is_a?(Hash)
-
{
-
"type" => "tool_use",
-
"id" => (h["id"] || h[:id]).to_s.presence,
-
"name" => (h["name"] || h[:name]).to_s.presence,
-
"input" => input
-
}.compact
-
when "text", "output_text"
-
{ "type" => "text", "text" => (h["text"] || h[:text]).to_s }
-
else
-
text = h["text"] || h[:text]
-
out = { "type" => type.presence || "text" }
-
out["text"] = text.to_s if text.is_a?(String) && text.present?
-
out
-
end
-
rescue StandardError
-
{ "type" => "text", "text" => "" }
-
end
-
-
def build_response(result, latency_ms)
-
success_response(
-
content: result[:content],
-
latency_ms: latency_ms,
-
input_tokens: result[:input_tokens],
-
output_tokens: result[:output_tokens],
-
provider_request: result[:provider_request],
-
provider_response: result[:provider_response],
-
provider_endpoint: result[:provider_endpoint]
-
).merge(
-
tool_calls: result[:tool_calls],
-
content_blocks: result[:content_blocks],
-
message_id: result[:message_id]
-
)
-
end
-
-
def handle_error(exception)
-
latency_ms = 0 # Error occurred, timing not meaningful
-
-
if rate_limit_error?(exception)
-
handle_rate_limit_error(exception, latency_ms)
-
else
-
handle_general_error(exception, latency_ms)
-
end
-
end
-
-
def handle_rate_limit_error(exception, latency_ms)
-
Rails.logger.warn("Anthropic rate limit exceeded: #{exception.message}")
-
retry_after = extract_retry_after(exception)
-
http_status = extract_http_status(exception)
-
error_response_hash = extract_error_response_hash(exception)
-
-
notify_error(exception, operation: "run", error_type: "rate_limit_exceeded", retry_after: retry_after, http_status: http_status)
-
-
error_response(
-
error: "Rate limit exceeded: #{exception.message}",
-
latency_ms: latency_ms,
-
error_type: "rate_limit",
-
rate_limit: true,
-
retry_after: retry_after,
-
provider_request: @last_provider_request,
-
provider_error_response: error_response_hash,
-
http_status: http_status,
-
response_headers: error_response_hash&.dig(:headers),
-
provider_endpoint: @last_provider_endpoint
-
)
-
end
-
-
def handle_general_error(exception, latency_ms)
-
Rails.logger.error("Anthropic request failed: #{exception.message}")
-
-
http_status = extract_http_status(exception)
-
error_response_hash = extract_error_response_hash(exception)
-
notify_error(exception, operation: "run", error_type: "request_failed", http_status: http_status)
-
-
error_response(
-
error: exception.message,
-
latency_ms: latency_ms,
-
error_type: exception.class.name,
-
provider_request: @last_provider_request,
-
provider_error_response: error_response_hash,
-
http_status: http_status,
-
response_headers: error_response_hash&.dig(:headers),
-
provider_endpoint: @last_provider_endpoint
-
)
-
end
-
-
# Rate limiting helpers
-
-
def rate_limiter
-
@rate_limiter ||= Scraping::AnthropicRateLimiterService.new
-
end
-
-
def record_token_usage(message)
-
input_tokens = message&.usage&.input_tokens
-
rate_limiter.record_tokens_used(input_tokens) if input_tokens
-
end
-
-
def estimate_tokens(text)
-
(text.length.to_f / 3.0).ceil
-
end
-
-
def rate_limit_error?(error)
-
message = error.message.to_s.downcase
-
return true if message.include?("rate_limit") || message.include?("rate limit") || message.include?("429")
-
return true if error.respond_to?(:status) && error.status == 429
-
check_response_for_rate_limit(error)
-
end
-
-
def check_response_for_rate_limit(error)
-
return false unless error.respond_to?(:response)
-
-
response = error.response
-
return false unless response.is_a?(Hash)
-
return true if response[:status] == 429 || response["status"] == 429
-
-
error_type = response.dig(:body, :error, :type) || response.dig("body", "error", "type")
-
error_type&.downcase&.include?("rate_limit") || false
-
end
-
-
def extract_retry_after(error)
-
return nil unless error.respond_to?(:response)
-
-
headers = error.response&.dig(:headers) || error.response&.dig("headers") || {}
-
retry_after = headers["retry-after"] || headers[:retry_after] || headers["Retry-After"]
-
retry_after&.to_i
-
rescue
-
nil
-
end
-
-
def extract_http_status(exception)
-
return nil unless exception.respond_to?(:response)
-
-
response = exception.response
-
return response[:status] || response["status"] if response.is_a?(Hash)
-
-
nil
-
rescue StandardError
-
nil
-
end
-
-
def extract_response_body(exception)
-
return nil unless exception.respond_to?(:response)
-
-
response = exception.response
-
return response[:body] || response["body"] if response.is_a?(Hash)
-
-
nil
-
rescue StandardError
-
nil
-
end
-
-
def extract_response_headers(exception)
-
return nil unless exception.respond_to?(:response)
-
-
response = exception.response
-
return response[:headers] || response["headers"] if response.is_a?(Hash)
-
-
nil
-
rescue StandardError
-
nil
-
end
-
-
def extract_error_response_hash(exception)
-
http_status = extract_http_status(exception)
-
body = extract_response_body(exception)
-
headers = extract_response_headers(exception)
-
return nil if http_status.blank? && body.blank? && headers.blank?
-
-
{
-
status: http_status,
-
headers: headers,
-
body: body
-
}.compact
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module LlmProviders
-
# Base provider class for LLM integrations
-
#
-
# Providers are responsible for:
-
# - Making API calls to LLM services
-
# - Error handling and rate limiting
-
# - Instrumentation (latency, token usage)
-
#
-
# Providers are NOT responsible for:
-
# - Building prompts (done by services)
-
# - Parsing responses (done by services)
-
#
-
# @abstract Subclass and override {#run} to implement
-
class BaseProvider
-
# Logging context attributes (set by caller for observability)
-
attr_accessor :scraping_attempt, :job_listing
-
-
# Sends a prompt to the LLM and returns the response
-
#
-
# @param prompt [String] The prompt text to send
-
# @param options [Hash] Optional parameters
-
# @option options [Integer] :max_tokens Maximum tokens in response
-
# @option options [Float] :temperature Temperature setting (0-1)
-
# @option options [String] :system_message Optional system message
-
# @option options [Array<Hash>] :media Array of media attachments for multimodal input
-
# Each media hash should contain:
-
# - :type [String] Media type: "image" or "document"
-
# - :source_type [String] Source type: "base64" or "url"
-
# - :media_type [String] MIME type (e.g., "image/png", "application/pdf")
-
# - :data [String] Base64-encoded data (if source_type is "base64")
-
# - :url [String] URL to media (if source_type is "url")
-
# @return [Hash] Result hash with:
-
# - :content [String] The LLM response text
-
# - :provider [String] Provider name
-
# - :model [String] Model used
-
# - :input_tokens [Integer] Input token count
-
# - :output_tokens [Integer] Output token count
-
# - :latency_ms [Integer] Request latency in milliseconds
-
# - :error [String, nil] Error message if failed
-
# - :rate_limit [Boolean] True if rate limited
-
# - :provider_request [Hash, nil] Provider-native request payload (best-effort, sanitized)
-
# - :provider_response [Hash, String, nil] Provider-native raw response payload (best-effort, sanitized)
-
# - :provider_error_response [Hash, String, nil] Provider-native raw error response payload (best-effort, sanitized)
-
# - :http_status [Integer, nil] HTTP status code if available
-
# - :response_headers [Hash, nil] HTTP response headers if available
-
# - :provider_endpoint [String, nil] Provider endpoint/base URL if available
-
# @raise [NotImplementedError] Must be implemented by subclass
-
def run(prompt, options = {})
-
raise NotImplementedError, "#{self.class} must implement #run"
-
end
-
-
# Checks if this provider supports multimodal input (images, documents)
-
#
-
# @return [Boolean] True if provider supports media attachments
-
def supports_media?
-
false
-
end
-
-
# Returns supported media types for this provider
-
#
-
# @return [Array<String>] Array of supported MIME types
-
def supported_media_types
-
[]
-
end
-
-
# Checks if the provider is available and configured
-
#
-
# @return [Boolean] True if provider can be used
-
def available?
-
api_key.present? && enabled?
-
end
-
-
# Returns the provider name (e.g., "anthropic", "openai")
-
#
-
# @return [String] Provider name
-
def provider_name
-
self.class.name.demodulize.gsub("Provider", "").downcase
-
end
-
-
# Returns the model name being used
-
#
-
# @return [String] Model name
-
def model_name
-
db_config&.llm_model || config["model"] || default_model
-
end
-
-
protected
-
-
# Returns the API key for this provider
-
#
-
# @return [String, nil] API key or nil if not configured
-
# @raise [NotImplementedError] Must be implemented by subclass
-
def api_key
-
raise NotImplementedError, "#{self.class} must implement #api_key"
-
end
-
-
# Returns the default model for this provider
-
#
-
# @return [String] Default model name
-
def default_model
-
"unknown"
-
end
-
-
# Returns the database configuration for this provider
-
#
-
# @return [LlmProviderConfig, nil] Provider configuration or nil
-
def db_config
-
@db_config ||= ::LlmProviderConfig.by_provider_type(provider_name).enabled.first
-
end
-
-
# Returns the configuration hash for this provider
-
#
-
# @return [Hash] Provider configuration
-
def config
-
@config ||= db_config&.to_config || {}
-
end
-
-
# Checks if provider is enabled in configuration
-
#
-
# @return [Boolean] True if enabled
-
def enabled?
-
db_config&.enabled? || false
-
end
-
-
# Returns max tokens from config or default
-
#
-
# @param default [Integer] Default value
-
# @return [Integer] Max tokens
-
def max_tokens_config(default: 4096)
-
db_config&.max_tokens || default
-
end
-
-
# Returns temperature from config or default
-
#
-
# @param default [Float] Default value
-
# @return [Float] Temperature
-
def temperature_config(default: 0)
-
db_config&.temperature || default
-
end
-
-
# Measures execution time and returns latency in ms
-
#
-
# @yield Block to measure
-
# @return [Array] [result, latency_ms]
-
def with_timing
-
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
-
result = yield
-
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
-
latency_ms = ((end_time - start_time) * 1000).round
-
[ result, latency_ms ]
-
end
-
-
# Builds a success response hash
-
#
-
# @param content [String] Response content
-
# @param latency_ms [Integer] Latency in milliseconds
-
# @param input_tokens [Integer, nil] Input token count
-
# @param output_tokens [Integer, nil] Output token count
-
# @return [Hash] Success response
-
def success_response(
-
content:,
-
latency_ms:,
-
input_tokens: nil,
-
output_tokens: nil,
-
provider_request: nil,
-
provider_response: nil,
-
http_status: nil,
-
response_headers: nil,
-
provider_endpoint: nil
-
)
-
response = {
-
content: content,
-
provider: provider_name,
-
model: model_name,
-
input_tokens: input_tokens,
-
output_tokens: output_tokens,
-
latency_ms: latency_ms
-
}
-
response[:provider_request] = provider_request if provider_request.present?
-
response[:provider_response] = provider_response if provider_response.present?
-
response[:http_status] = http_status if http_status.present?
-
response[:response_headers] = response_headers if response_headers.present?
-
response[:provider_endpoint] = provider_endpoint if provider_endpoint.present?
-
response
-
end
-
-
# Builds an error response hash
-
#
-
# @param error [String] Error message
-
# @param latency_ms [Integer] Latency in milliseconds
-
# @param error_type [String] Error classification
-
# @param rate_limit [Boolean] Whether this is a rate limit error
-
# @param retry_after [Integer, nil] Seconds to wait before retry
-
# @return [Hash] Error response
-
def error_response(
-
error:,
-
latency_ms:,
-
error_type: nil,
-
rate_limit: false,
-
retry_after: nil,
-
provider_request: nil,
-
provider_error_response: nil,
-
http_status: nil,
-
response_headers: nil,
-
provider_endpoint: nil
-
)
-
response = {
-
content: nil,
-
error: error,
-
provider: provider_name,
-
model: model_name,
-
error_type: error_type,
-
latency_ms: latency_ms
-
}
-
response[:rate_limit] = true if rate_limit
-
response[:retry_after] = retry_after if retry_after
-
response[:provider_request] = provider_request if provider_request.present?
-
response[:provider_error_response] = provider_error_response if provider_error_response.present?
-
response[:http_status] = http_status if http_status.present?
-
response[:response_headers] = response_headers if response_headers.present?
-
response[:provider_endpoint] = provider_endpoint if provider_endpoint.present?
-
response
-
end
-
-
# Notifies about an AI error
-
#
-
# @param exception [Exception] The exception
-
# @param context [Hash] Additional context
-
def notify_error(exception, context = {})
-
ExceptionNotifier.notify_ai_error(exception, {
-
provider_name: provider_name,
-
model_identifier: model_name
-
}.merge(context))
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module LlmProviders
-
# Ollama provider for self-hosted LLM completions
-
#
-
# Uses the Ollama REST API for local model inference.
-
# Does not require an API key - connects to local Ollama server.
-
class OllamaProvider < BaseProvider
-
REQUEST_TIMEOUT = 120 # Longer timeout for local inference
-
-
# Sends a prompt to Ollama and returns the response
-
#
-
# @param prompt [String] The prompt text
-
# @param options [Hash] Optional parameters (temperature not supported by Ollama)
-
# @return [Hash] Result with content and metadata
-
def run(prompt, options = {})
-
result, latency_ms = with_timing { call_api(prompt, options) }
-
build_response(result, latency_ms)
-
rescue => e
-
handle_error(e)
-
end
-
-
# Ollama doesn't require an API key
-
def available?
-
enabled? && ollama_endpoint.present?
-
end
-
-
protected
-
-
def api_key
-
"local" # Dummy value for availability check
-
end
-
-
def default_model
-
"llama3"
-
end
-
-
private
-
-
def call_api(prompt, options)
-
request_body = build_request_body(prompt, options)
-
@last_provider_request = request_body
-
-
response = HTTParty.post(
-
"#{ollama_endpoint}/api/generate",
-
headers: { "Content-Type" => "application/json" },
-
body: request_body.to_json,
-
timeout: REQUEST_TIMEOUT
-
)
-
-
parse_response(response, provider_request: request_body)
-
end
-
-
def build_request_body(prompt, options)
-
body = {
-
model: model_name,
-
prompt: prompt,
-
stream: false
-
}
-
-
# Only request JSON format if caller expects it
-
body[:format] = "json" if options[:json_format]
-
body
-
end
-
-
def parse_response(response, provider_request:)
-
unless response.success?
-
return {
-
error: "Ollama request failed: #{response.code}",
-
provider_request: provider_request,
-
provider_error_response: {
-
status: response.code,
-
headers: (response.respond_to?(:headers) ? response.headers.to_h : nil),
-
body: response.body
-
}.compact,
-
http_status: response.code,
-
response_headers: (response.respond_to?(:headers) ? response.headers.to_h : nil),
-
provider_endpoint: ollama_endpoint
-
}
-
end
-
-
parsed = response.parsed_response
-
-
{
-
content: parsed["response"],
-
input_tokens: parsed["prompt_eval_count"],
-
output_tokens: parsed["eval_count"],
-
provider_request: provider_request,
-
provider_response: parsed,
-
http_status: response.code,
-
response_headers: (response.respond_to?(:headers) ? response.headers.to_h : nil),
-
provider_endpoint: ollama_endpoint
-
}
-
end
-
-
def build_response(result, latency_ms)
-
if result[:error]
-
error_response(
-
error: result[:error],
-
latency_ms: latency_ms,
-
error_type: "http_error",
-
provider_request: result[:provider_request],
-
provider_error_response: result[:provider_error_response],
-
http_status: result[:http_status],
-
response_headers: result[:response_headers],
-
provider_endpoint: result[:provider_endpoint]
-
)
-
else
-
success_response(
-
content: result[:content],
-
latency_ms: latency_ms,
-
input_tokens: result[:input_tokens],
-
output_tokens: result[:output_tokens],
-
provider_request: result[:provider_request],
-
provider_response: result[:provider_response],
-
http_status: result[:http_status],
-
response_headers: result[:response_headers],
-
provider_endpoint: result[:provider_endpoint]
-
)
-
end
-
end
-
-
def handle_error(exception)
-
Rails.logger.error("Ollama request failed: #{exception.message}")
-
-
notify_error(exception, operation: "run", error_type: "request_failed", endpoint: ollama_endpoint)
-
-
error_response(
-
error: exception.message,
-
latency_ms: 0,
-
error_type: exception.class.name,
-
provider_request: @last_provider_request,
-
provider_endpoint: ollama_endpoint
-
)
-
end
-
-
def ollama_endpoint
-
db_config&.api_endpoint || "http://localhost:11434"
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module LlmProviders
-
# OpenAI provider for LLM completions
-
#
-
# Uses the OpenAI Responses API for better reliability and structured outputs.
-
# Reference: https://platform.openai.com/docs/api-reference/responses
-
#
-
# Supports multimodal input:
-
# - Images: JPEG, PNG, GIF, WebP
-
# - Documents: PDF, DOCX (via file input)
-
class OpenaiProvider < BaseProvider
-
# Request timeout in seconds for API calls
-
REQUEST_TIMEOUT = 120
-
-
# Supported image MIME types
-
SUPPORTED_IMAGE_TYPES = %w[
-
image/jpeg
-
image/png
-
image/gif
-
image/webp
-
].freeze
-
-
# Supported document MIME types
-
SUPPORTED_DOCUMENT_TYPES = %w[
-
application/pdf
-
application/vnd.openxmlformats-officedocument.wordprocessingml.document
-
].freeze
-
-
# All supported media types
-
SUPPORTED_MEDIA_TYPES = (SUPPORTED_IMAGE_TYPES + SUPPORTED_DOCUMENT_TYPES).freeze
-
-
# Sends a prompt to OpenAI and returns the response
-
#
-
# @param prompt [String] The prompt text
-
# @param options [Hash] Optional parameters
-
# @option options [Integer] :max_tokens Maximum tokens in response
-
# @option options [Float] :temperature Temperature setting
-
# @option options [String] :system_message Optional system message
-
# @option options [Array<Hash>] :media Array of media attachments (images, documents)
-
# @return [Hash] Result with content and metadata
-
def run(prompt, options = {})
-
result, latency_ms = with_timing { call_api(prompt, options) }
-
build_response(result, latency_ms)
-
rescue JSON::ParserError => e
-
handle_json_error(e)
-
rescue => e
-
handle_error(e)
-
end
-
-
# @return [Boolean] True - OpenAI GPT-4o supports multimodal input
-
def supports_media?
-
true
-
end
-
-
# @return [Array<String>] Supported MIME types for media attachments
-
def supported_media_types
-
SUPPORTED_MEDIA_TYPES
-
end
-
-
protected
-
-
def api_key
-
Rails.application.credentials.dig(:openai, :api_key)
-
end
-
-
def default_model
-
"gpt-4o-mini"
-
end
-
-
private
-
-
def call_api(prompt, options)
-
@last_provider_request = build_params(prompt, options)
-
@last_provider_endpoint =
-
if Setting.helicone_enabled?
-
Rails.application.credentials.dig(:helicone, :base_url)
-
else
-
nil
-
end
-
-
if Setting.helicone_enabled?
-
client = OpenAI::Client.new(
-
access_token: Rails.application.credentials.dig(:helicone, :api_key),
-
uri_base: Rails.application.credentials.dig(:helicone, :base_url),
-
request_timeout: REQUEST_TIMEOUT
-
)
-
else
-
client = OpenAI::Client.new(
-
access_token: api_key,
-
request_timeout: REQUEST_TIMEOUT
-
)
-
end
-
-
response = client.responses.create(parameters: @last_provider_request)
-
parse_response(response, provider_request: @last_provider_request, provider_endpoint: @last_provider_endpoint)
-
end
-
-
def build_params(prompt, options)
-
input = build_input(prompt, options)
-
-
params = {
-
model: model_name,
-
input: input,
-
temperature: options[:temperature] || temperature_config,
-
max_output_tokens: options[:max_tokens] || max_tokens_config(default: 16384)
-
}
-
-
params[:previous_response_id] = options[:previous_response_id] if options[:previous_response_id].present?
-
-
if options[:tools].present?
-
params[:tools] = options[:tools]
-
params[:tool_choice] = options[:tool_choice] if options.key?(:tool_choice)
-
end
-
-
params
-
end
-
-
def build_input(prompt, options)
-
# Supports both legacy prompt string and structured input/messages for tool calling.
-
#
-
# Legacy: prompt + optional system_message -> [{role, content}, ...]
-
# Tool calling: options[:messages] already formatted as [{role, content}, ...]
-
input = []
-
-
if options[:messages].present?
-
messages = Array(options[:messages])
-
# Inject media into the last user message if provided
-
if options[:media].present?
-
messages = inject_media_into_messages(messages, options[:media])
-
end
-
input.concat(messages)
-
else
-
system_message = options[:system_message]
-
input << { role: "system", content: system_message } if system_message.present?
-
# For continuation requests (previous_response_id + tool_outputs), we may not need to add a user message.
-
# Never send `content: nil` (OpenAI rejects it with invalid_type).
-
prompt_text = prompt.to_s
-
if prompt_text.present?
-
content = build_content_with_media(prompt_text, options[:media])
-
input << { role: "user", content: content }
-
end
-
end
-
-
Array(options[:tool_outputs]).each do |tool_output|
-
call_id = tool_output[:call_id] || tool_output["call_id"] || tool_output[:tool_call_id] || tool_output["tool_call_id"]
-
output = tool_output[:output] || tool_output["output"]
-
next if call_id.blank?
-
-
# Responses API commonly accepts function_call_output items; keep it provider-specific here.
-
input << {
-
type: "function_call_output",
-
call_id: call_id,
-
output: output.is_a?(String) ? output : output.to_json
-
}
-
end
-
-
input
-
end
-
-
# Builds content array with text and optional media blocks for OpenAI
-
#
-
# @param text [String] The text content
-
# @param media [Array<Hash>, nil] Optional media attachments
-
# @return [String, Array<Hash>] String if no media, Array of content blocks otherwise
-
def build_content_with_media(text, media)
-
return text.to_s if media.blank?
-
-
content_blocks = []
-
-
# Add text block first
-
content_blocks << { type: "text", text: text.to_s } if text.present?
-
-
# Add media blocks
-
Array(media).each do |m|
-
block = build_media_block(m)
-
content_blocks << block if block
-
end
-
-
content_blocks.size == 1 && content_blocks.first[:type] == "text" ? text.to_s : content_blocks
-
end
-
-
# Builds a single media content block for OpenAI's API
-
#
-
# OpenAI uses different formats:
-
# - Images: { type: "image_url", image_url: { url: "data:image/jpeg;base64,..." } }
-
# - Files (Responses API): { type: "input_file", file_id: "..." } or inline via base64
-
#
-
# @param media [Hash] Media attachment info
-
# - :type [String] "image" or "document"
-
# - :source_type [String] "base64" or "url"
-
# - :media_type [String] MIME type
-
# - :data [String] Base64 data (if source_type is "base64")
-
# - :url [String] URL (if source_type is "url")
-
# - :file_id [String] OpenAI file ID (if already uploaded)
-
# @return [Hash, nil] Content block for OpenAI API or nil if invalid
-
def build_media_block(media)
-
media = media.symbolize_keys
-
media_type = media[:media_type].to_s
-
-
return nil unless SUPPORTED_MEDIA_TYPES.include?(media_type)
-
-
if SUPPORTED_IMAGE_TYPES.include?(media_type)
-
build_image_block(media)
-
else
-
build_file_block(media)
-
end
-
end
-
-
# Builds an image content block for OpenAI
-
# Format: { type: "image_url", image_url: { url: "..." } }
-
def build_image_block(media)
-
url = if media[:source_type].to_s == "url" && media[:url].present?
-
media[:url]
-
elsif media[:data].present?
-
"data:#{media[:media_type]};base64,#{media[:data]}"
-
end
-
-
return nil unless url
-
-
{
-
type: "image_url",
-
image_url: { url: url, detail: media[:detail] || "auto" }
-
}
-
end
-
-
# Builds a file content block for OpenAI (PDF, DOCX)
-
# For Responses API, uses input_file with file_id or inline base64
-
def build_file_block(media)
-
# If we have a file_id (already uploaded to OpenAI), use it
-
if media[:file_id].present?
-
return {
-
type: "input_file",
-
file_id: media[:file_id]
-
}
-
end
-
-
# For base64 data, we can use inline file content
-
# Note: OpenAI Responses API supports inline file via base64
-
if media[:data].present?
-
return {
-
type: "input_file",
-
filename: media[:filename] || default_filename_for(media[:media_type]),
-
file_data: "data:#{media[:media_type]};base64,#{media[:data]}"
-
}
-
end
-
-
# URL-based files need to be downloaded and uploaded to OpenAI first
-
# For now, we don't support URL-based files directly
-
nil
-
end
-
-
# Returns a default filename based on media type
-
def default_filename_for(media_type)
-
case media_type
-
when "application/pdf"
-
"document.pdf"
-
when "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
-
"document.docx"
-
else
-
"file"
-
end
-
end
-
-
# Injects media into the last user message in a messages array
-
#
-
# @param messages [Array<Hash>] Existing messages
-
# @param media [Array<Hash>] Media to inject
-
# @return [Array<Hash>] Messages with media injected
-
def inject_media_into_messages(messages, media)
-
return messages if media.blank?
-
-
# Find the last user message
-
last_user_idx = messages.rindex { |m| m[:role] == "user" || m["role"] == "user" }
-
return messages unless last_user_idx
-
-
messages = messages.deep_dup
-
last_msg = messages[last_user_idx]
-
existing_content = last_msg[:content] || last_msg["content"]
-
-
# Convert string content to content blocks with media
-
if existing_content.is_a?(String)
-
new_content = build_content_with_media(existing_content, media)
-
elsif existing_content.is_a?(Array)
-
# Already an array of content blocks, append media
-
media_blocks = Array(media).filter_map { |m| build_media_block(m) }
-
new_content = existing_content + media_blocks
-
else
-
new_content = build_content_with_media("", media)
-
end
-
-
messages[last_user_idx] = last_msg.merge(content: new_content)
-
messages
-
end
-
-
def parse_response(response, provider_request:, provider_endpoint:)
-
response_data = response.is_a?(Hash) ? response : response.to_h
-
-
content = extract_content(response_data)
-
tool_calls = extract_tool_calls(response_data)
-
response_id = response_data["id"] || response_data[:id]
-
usage = response_data["usage"] || {}
-
-
response_id = response_data["id"] || response_data[:id]
-
response_id = response_id.to_s
-
response_id = "unknown" if response_id.blank?
-
parsed = {
-
raw_response: response_data,
-
content: content,
-
tool_calls: tool_calls,
-
response_id: response_id,
-
provider_request: provider_request,
-
provider_response: response_data,
-
provider_endpoint: provider_endpoint,
-
input_tokens: usage["input_tokens"],
-
output_tokens: usage["output_tokens"]
-
}
-
-
contract = Assistant::Contracts::ProviderResultContracts::Openai.call(parsed)
-
unless contract.success?
-
notify_error(RuntimeError.new("OpenAI provider contract failed"), operation: "parse_response", error_type: "contract_failed", contract_errors: contract.errors.to_h)
-
end
-
-
parsed
-
end
-
-
def extract_content(response_data)
-
# Responses API structure: output -> [{ type: "message", content: [{ type: "output_text", text: "..." }] }]
-
output = response_data["output"]
-
return "" unless output.is_a?(Array)
-
-
message = output.find { |o| o["type"] == "message" }
-
return "" unless message
-
-
content_blocks = message["content"]
-
return "" unless content_blocks.is_a?(Array)
-
-
text_block = content_blocks.find { |c| c["type"] == "output_text" }
-
text_block&.dig("text") || ""
-
end
-
-
def extract_tool_calls(response_data)
-
output = response_data["output"]
-
return [] unless output.is_a?(Array)
-
-
calls = output.select { |o| o.is_a?(Hash) && o["type"].to_s.include?("function_call") }
-
-
calls.map do |call|
-
{
-
id: call["call_id"] || call["id"],
-
tool_key: call["name"] || call.dig("function", "name"),
-
args: parse_tool_args(call["arguments"] || call.dig("function", "arguments"))
-
}
-
end.select { |c| c[:tool_key].present? }
-
end
-
-
def parse_tool_args(value)
-
return {} if value.blank?
-
return value if value.is_a?(Hash)
-
-
JSON.parse(value.to_s)
-
rescue JSON::ParserError
-
{}
-
end
-
-
def build_response(result, latency_ms)
-
success_response(
-
content: result[:content],
-
latency_ms: latency_ms,
-
input_tokens: result[:input_tokens],
-
output_tokens: result[:output_tokens],
-
provider_request: result[:provider_request],
-
provider_response: result[:provider_response],
-
provider_endpoint: result[:provider_endpoint]
-
).merge(
-
tool_calls: result[:tool_calls],
-
response_id: result[:response_id]
-
)
-
end
-
-
def handle_json_error(exception)
-
Rails.logger.error("OpenAI JSON parsing failed: #{exception.message}")
-
-
notify_error(exception, operation: "run", error_type: "json_parsing")
-
-
error_response(
-
error: "Invalid JSON response: #{exception.message}",
-
latency_ms: 0,
-
error_type: "json_parsing"
-
)
-
end
-
-
def handle_error(exception)
-
Rails.logger.error("OpenAI request failed: #{exception.message}")
-
-
http_status = extract_http_status(exception)
-
error_response_hash = extract_error_response_hash(exception)
-
notify_error(exception, operation: "run", error_type: "request_failed", http_status: http_status)
-
-
error_response(
-
error: exception.message,
-
latency_ms: 0,
-
error_type: exception.class.name,
-
provider_request: @last_provider_request,
-
provider_error_response: error_response_hash,
-
http_status: http_status,
-
response_headers: error_response_hash&.dig(:headers),
-
provider_endpoint: @last_provider_endpoint
-
)
-
end
-
-
def extract_http_status(exception)
-
return nil unless exception.respond_to?(:response)
-
-
response = exception.response
-
if response.is_a?(Hash)
-
response[:status] || response["status"] || response[:code] || response["code"]
-
elsif response.respond_to?(:code)
-
response.code
-
elsif response.respond_to?(:status)
-
response.status
-
end
-
end
-
-
def extract_response_body(exception)
-
return nil unless exception.respond_to?(:response)
-
-
response = exception.response
-
if response.is_a?(Hash)
-
body = response[:body] || response["body"]
-
body.is_a?(String) ? body : body.to_s
-
else
-
response.to_s
-
end
-
rescue StandardError
-
nil
-
end
-
-
def extract_response_headers(exception)
-
return nil unless exception.respond_to?(:response)
-
-
response = exception.response
-
return response[:headers] || response["headers"] if response.is_a?(Hash)
-
-
nil
-
rescue StandardError
-
nil
-
end
-
-
def extract_error_response_hash(exception)
-
http_status = extract_http_status(exception)
-
body = extract_response_body(exception)
-
headers = extract_response_headers(exception)
-
return nil if http_status.blank? && body.blank? && headers.blank?
-
-
{
-
status: http_status,
-
headers: headers,
-
body: body
-
}.compact
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module LlmProviders
-
# Configuration helper for LLM providers (database-backed)
-
#
-
# Provides access to provider configuration from LlmProviderConfig model.
-
# Used by services to determine which providers to use and in what order.
-
#
-
# @example
-
# LlmProviders::ProviderConfigHelper.default_provider # => "anthropic"
-
# LlmProviders::ProviderConfigHelper.fallback_providers # => ["openai", "ollama"]
-
# LlmProviders::ProviderConfigHelper.all_providers # => ["anthropic", "openai", "ollama"]
-
#
-
module ProviderConfigHelper
-
class << self
-
# Returns the default provider name
-
#
-
# @return [String] Default provider name
-
def default_provider
-
::LlmProviderConfig.default_provider&.provider_type || "anthropic"
-
end
-
-
# Returns the list of fallback provider names
-
#
-
# @return [Array<String>] Fallback provider names
-
def fallback_providers
-
::LlmProviderConfig.fallback_providers.pluck(:provider_type)
-
end
-
-
# Returns all available providers in priority order
-
#
-
# @return [Array<String>] Provider names
-
def all_providers
-
([ default_provider ] + fallback_providers).uniq
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
require "redcarpet"
-
require "rouge"
-
require "rouge/plugins/redcarpet"
-
-
# MarkdownRenderer converts markdown text into safe HTML with syntax highlighting.
-
#
-
# Uses Redcarpet for markdown parsing, Rouge for syntax highlighting,
-
# and extracts a table of contents from headings.
-
#
-
# @example Basic usage (static method)
-
# html = MarkdownRenderer.render(markdown_text)
-
#
-
# @example With TOC extraction (instance method)
-
# renderer = MarkdownRenderer.new(markdown)
-
# result = renderer.render
-
# result[:html] # => safe HTML string with syntax-highlighted code
-
# result[:toc] # => [{level: 2, id: "section", text: "Section"}, ...]
-
# result[:reading_time_minutes] # => Integer
-
#
-
class MarkdownRenderer
-
attr_reader :markdown
-
-
def initialize(markdown)
-
@markdown = markdown.to_s
-
end
-
-
# Instance method for rendering with TOC extraction
-
# @return [Hash{Symbol=>Object}]
-
def render
-
result = self.class.render_with_toc(markdown)
-
-
{
-
html: result[:html],
-
toc: result[:toc],
-
reading_time_minutes: reading_time_minutes
-
}
-
end
-
-
# Static method for simple HTML rendering
-
# @param text [String] The markdown text to render
-
# @return [String] Safe HTML string
-
def self.render(text)
-
renderer = HtmlRenderer.new
-
markdown = Redcarpet::Markdown.new(renderer, markdown_extensions)
-
markdown.render(text).html_safe
-
end
-
-
# Static method for rendering with TOC extraction
-
# @param text [String] The markdown text to render
-
# @return [Hash{Symbol=>Object}]
-
def self.render_with_toc(text)
-
renderer = HtmlRenderer.new
-
markdown = Redcarpet::Markdown.new(renderer, markdown_extensions)
-
html = markdown.render(text).html_safe
-
{ html: html, toc: renderer.toc_items }
-
end
-
-
private
-
-
def self.markdown_extensions
-
{
-
autolink: true,
-
tables: true,
-
fenced_code_blocks: true,
-
strikethrough: true,
-
highlight: true,
-
superscript: true,
-
underline: true,
-
no_intra_emphasis: true,
-
space_after_headers: true,
-
lax_spacing: true
-
}
-
end
-
-
# Rough reading time estimate assuming 200 wpm.
-
# @return [Integer]
-
def reading_time_minutes
-
words = markdown.scan(/\b[\p{L}\p{N}']+\b/).size
-
[ (words / 200.0).ceil, 1 ].max
-
end
-
-
# Inner HTML renderer class with Rouge syntax highlighting
-
class HtmlRenderer < Redcarpet::Render::HTML
-
include Rouge::Plugins::Redcarpet
-
-
# @return [Array<Hash>] Table of contents entries collected during rendering
-
attr_reader :toc_items
-
-
def initialize(extensions = {})
-
super(extensions.merge(
-
hard_wrap: true,
-
link_attributes: { target: "_blank", rel: "noopener noreferrer" },
-
with_toc_data: true
-
))
-
@toc_items = []
-
@heading_ids = Hash.new(0)
-
end
-
-
# Custom block code rendering with Rouge using CSS classes
-
def block_code(code, language)
-
language ||= "text"
-
lexer = Rouge::Lexer.find_fancy(language, code) || Rouge::Lexers::PlainText.new
-
-
# Use HTML formatter with CSS classes (not inline styles)
-
formatter = Rouge::Formatters::HTML.new
-
highlighted = formatter.format(lexer.lex(code))
-
-
lang_label = language != "text" ? %(<span class="code-lang">#{language}</span>) : ""
-
%(<div class="code-block">#{lang_label}<pre class="highlight #{language}"><code>#{highlighted}</code></pre></div>)
-
end
-
-
# Add classes to paragraphs for styling
-
def paragraph(text)
-
%(<p>#{text}</p>\n)
-
end
-
-
# Style blockquotes
-
def block_quote(quote)
-
%(<blockquote>#{quote}</blockquote>\n)
-
end
-
-
# Add anchor links to headers and collect TOC
-
def header(text, header_level)
-
base_slug = text.downcase.strip.gsub(/\s+/, "-").gsub(/[^\w-]/, "")
-
base_slug = "section" if base_slug.blank?
-
-
@heading_ids[base_slug] += 1
-
slug = @heading_ids[base_slug] > 1 ? "#{base_slug}-#{@heading_ids[base_slug]}" : base_slug
-
-
# Collect TOC items for h2, h3, h4
-
if header_level >= 2 && header_level <= 4
-
@toc_items << { level: header_level, id: slug, text: text }
-
end
-
-
%(<h#{header_level} id="#{slug}">#{text}</h#{header_level}>\n)
-
end
-
-
# Style horizontal rules
-
def hrule
-
%(<hr class="my-8">\n)
-
end
-
-
# Style tables
-
def table(header, body)
-
%(<table class="doc-table"><thead>#{header}</thead><tbody>#{body}</tbody></table>\n)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# Service for authenticating or creating a user from OAuth data
-
#
-
# @example
-
# service = OauthAuthenticationService.new(auth_hash)
-
# user = service.run
-
#
-
class OauthAuthenticationService
-
# Initialize the service with OAuth authentication hash
-
#
-
# @param [OmniAuth::AuthHash] auth_hash The OAuth authentication data
-
def initialize(auth_hash)
-
@auth = auth_hash
-
@provider = auth_hash.provider
-
@uid = auth_hash.uid
-
@email = auth_hash.info.email
-
@name = auth_hash.info.name
-
end
-
-
# Runs the service to find or create a user from OAuth data
-
#
-
# @return [User] The authenticated or created user
-
# @raise [ActiveRecord::RecordInvalid] If user creation fails
-
def run
-
user = find_user_by_oauth || find_user_by_email || create_user
-
update_oauth_fields(user) unless user.oauth_provider.present?
-
-
# Create ConnectedAccount for Google OAuth if it doesn't exist
-
create_connected_account(user) if @provider == "google_oauth2"
-
-
user
-
end
-
-
private
-
-
# Finds a user by OAuth provider and UID
-
#
-
# @return [User, nil] The user if found
-
def find_user_by_oauth
-
User.find_by(oauth_provider: @provider, oauth_uid: @uid)
-
end
-
-
# Finds a user by email address
-
#
-
# @return [User, nil] The user if found
-
def find_user_by_email
-
User.find_by(email_address: @email)
-
end
-
-
# Creates a new user with OAuth data
-
#
-
# @return [User] The newly created user
-
# @raise [ActiveRecord::RecordInvalid] If validation fails
-
def create_user
-
random_password = SecureRandom.hex(32)
-
-
User.create!(
-
email_address: @email,
-
name: @name,
-
password: random_password,
-
password_confirmation: random_password,
-
oauth_provider: @provider,
-
oauth_uid: @uid,
-
email_verified_at: Time.current, # OAuth users are auto-verified
-
terms_accepted_at: Time.current, # OAuth sign-up implies terms acceptance
-
terms_accepted: true # Satisfy the validation
-
)
-
end
-
-
# Updates OAuth fields for an existing user
-
#
-
# @param user [User] The user to update
-
# @return [Boolean] True if update succeeds
-
def update_oauth_fields(user)
-
user.update(
-
oauth_provider: @provider,
-
oauth_uid: @uid,
-
email_verified_at: Time.current # Mark as verified when linking OAuth
-
)
-
end
-
-
# Creates a ConnectedAccount for the user if it doesn't exist
-
#
-
# @param user [User] The user to create the connected account for
-
# @return [ConnectedAccount, nil] The created or existing connected account
-
def create_connected_account(user)
-
# Check if account already exists by provider and uid
-
existing_account = user.connected_accounts.find_by(
-
provider: @provider,
-
uid: @uid
-
)
-
-
return existing_account if existing_account
-
-
# Create new ConnectedAccount using the from_oauth method
-
ConnectedAccount.from_oauth(user, @auth)
-
end
-
end
-
# frozen_string_literal: true
-
-
module Opportunities
-
# Service for creating an interview application from an opportunity
-
#
-
# Handles the full apply flow:
-
# 1. Find or create Company from extracted name
-
# 2. Find or create JobRole from extracted title
-
# 3. If URL exists: create JobListing + trigger scraping
-
# 4. If no URL: create application without job listing
-
# 5. Link opportunity to interview_application
-
#
-
# @example
-
# service = Opportunities::CreateApplicationService.new(opportunity, user)
-
# result = service.call
-
# if result[:success]
-
# redirect_to result[:application]
-
# end
-
#
-
class CreateApplicationService
-
attr_reader :opportunity, :user
-
-
# Initialize the service
-
#
-
# @param opportunity [Opportunity] The opportunity to create an application from
-
# @param user [User] The user creating the application
-
def initialize(opportunity, user)
-
@opportunity = opportunity
-
@user = user
-
end
-
-
# Creates the interview application
-
#
-
# @return [Hash] Result with success status and application
-
def call
-
return error_result("Opportunity not found") unless opportunity
-
return error_result("User not found") unless user
-
return error_result("Already applied") if opportunity.applied?
-
-
ActiveRecord::Base.transaction do
-
# Find or create company
-
company = find_or_create_company
-
-
# Find or create job role
-
job_role = find_or_create_job_role
-
-
# Prefer an existing job_listing already associated with the opportunity.
-
job_listing = opportunity.job_listing
-
-
# Create job listing if we have a URL and no job_listing is present yet.
-
job_listing ||= create_job_listing_if_url_present(company, job_role)
-
-
# Persist the job listing back onto the opportunity for reuse.
-
opportunity.update!(job_listing: job_listing) if job_listing.present? && opportunity.job_listing_id != job_listing.id
-
-
# Create the interview application
-
application = create_application(company, job_role, job_listing)
-
-
# Link opportunity to application and mark as applied
-
opportunity.update!(
-
interview_application: application
-
)
-
opportunity.mark_applied!
-
-
# Trigger job listing scraping in background if we have a URL
-
if job_listing.present?
-
ScrapeJobListingJob.perform_later(job_listing)
-
end
-
-
{
-
success: true,
-
application: application,
-
job_listing: job_listing,
-
company: company,
-
job_role: job_role
-
}
-
end
-
rescue ActiveRecord::RecordInvalid => e
-
Rails.logger.error("Opportunities::CreateApplicationService validation error: #{e.message}")
-
error_result(e.message)
-
rescue StandardError => e
-
Rails.logger.error("Opportunities::CreateApplicationService error: #{e.message}")
-
error_result("Failed to create application: #{e.message}")
-
end
-
-
private
-
-
# Finds or creates a company from the opportunity
-
#
-
# @return [Company]
-
def find_or_create_company
-
company_name = opportunity.company_name.presence || "Unknown Company"
-
-
# Normalize name
-
normalized_name = normalize_company_name(company_name)
-
-
# Try to find existing
-
company = Company.find_by("LOWER(name) = ?", normalized_name.downcase)
-
return company if company
-
-
# Create new company
-
Company.create!(name: normalized_name)
-
end
-
-
# Finds or creates a job role from the opportunity
-
#
-
# @return [JobRole]
-
def find_or_create_job_role
-
role_title = opportunity.job_role_title.presence || "Unknown Position"
-
-
# Try to find existing
-
job_role = JobRole.find_by("LOWER(title) = ?", role_title.downcase)
-
return assign_department_if_missing(job_role) if job_role
-
-
# Create new job role with department if available
-
job_role = JobRole.create!(title: role_title)
-
assign_department_if_missing(job_role)
-
end
-
-
# Assigns department to job role if not already set
-
#
-
# @param job_role [JobRole]
-
# @return [JobRole]
-
def assign_department_if_missing(job_role)
-
return job_role if job_role.category_id.present?
-
-
# Try to get department from extracted data
-
department_name = opportunity.extracted_data&.dig("job_role_department")
-
-
if department_name.present?
-
department = Category.find_by(name: department_name, kind: :job_role)
-
job_role.update(category: department) if department
-
else
-
# Try to infer from title
-
department = infer_department_from_title(job_role.title)
-
job_role.update(category: department) if department
-
end
-
-
job_role
-
end
-
-
# Infers department from job role title using keyword matching
-
#
-
# @param title [String]
-
# @return [Category, nil]
-
def infer_department_from_title(title)
-
return nil if title.blank?
-
-
title_lower = title.downcase
-
-
department_keywords = {
-
"Engineering" => %w[engineer developer software backend frontend fullstack architect sre devops platform],
-
"Product" => %w[product owner manager pm],
-
"Design" => %w[designer ux ui visual graphic],
-
"Data Science" => %w[data scientist analyst analytics machine learning ml ai],
-
"Sales" => %w[sales account executive ae sdr bdr],
-
"Marketing" => %w[marketing growth seo sem content brand],
-
"HR/People" => %w[hr human resources people talent recruiter recruiting],
-
"Finance" => %w[finance accounting financial],
-
"Executive" => %w[ceo cto coo cfo cmo chief director vp president]
-
}
-
-
department_keywords.each do |dept_name, keywords|
-
if keywords.any? { |kw| title_lower.include?(kw) }
-
return Category.find_by(name: dept_name, kind: :job_role)
-
end
-
end
-
-
nil
-
end
-
-
# Creates a job listing if we have a URL
-
#
-
# @param company [Company]
-
# @param job_role [JobRole]
-
# @return [JobListing, nil]
-
def create_job_listing_if_url_present(company, job_role)
-
return nil unless opportunity.job_url.present?
-
-
res = JobListings::UpsertFromUrlService.new(
-
url: opportunity.job_url,
-
company: company,
-
job_role: job_role,
-
title: opportunity.job_role_title
-
).call
-
res[:job_listing]
-
end
-
-
# Creates the interview application
-
#
-
# @param company [Company]
-
# @param job_role [JobRole]
-
# @param job_listing [JobListing, nil]
-
# @return [InterviewApplication]
-
def create_application(company, job_role, job_listing)
-
user.interview_applications.create!(
-
company: company,
-
job_role: job_role,
-
job_listing: job_listing,
-
applied_at: Time.current,
-
notes: build_application_notes
-
)
-
end
-
-
# Builds notes for the application from opportunity data
-
#
-
# @return [String, nil]
-
def build_application_notes
-
notes_parts = []
-
-
notes_parts << "Source: #{opportunity.source_type_display}" if opportunity.source_type.present?
-
-
if opportunity.recruiter_name.present? || opportunity.recruiter_email.present?
-
recruiter_info = [ opportunity.recruiter_name, opportunity.recruiter_email ].compact.join(" - ")
-
notes_parts << "Recruiter: #{recruiter_info}"
-
end
-
-
notes_parts << "Key details: #{opportunity.key_details}" if opportunity.key_details.present?
-
-
notes_parts.any? ? notes_parts.join("\n\n") : nil
-
end
-
-
# Normalizes company name
-
#
-
# @param name [String]
-
# @return [String]
-
def normalize_company_name(name)
-
# Remove common suffixes
-
normalized = name.strip
-
suffixes = [
-
/\s+inc\.?$/i,
-
/\s+llc\.?$/i,
-
/\s+corp\.?$/i,
-
/\s+ltd\.?$/i,
-
/\s+co\.?$/i
-
]
-
-
suffixes.each { |suffix| normalized = normalized.gsub(suffix, "") }
-
normalized.strip.titleize
-
end
-
-
# Extracts source ID from URL
-
#
-
# @param url [String]
-
# @return [String, nil]
-
def extract_source_id(url)
-
match = url.match(%r{/(jobs?|careers?|positions?)/([^/\?]+)})
-
match ? match[2] : nil
-
end
-
-
# Returns an error result
-
#
-
# @param message [String]
-
# @return [Hash]
-
def error_result(message)
-
{
-
success: false,
-
error: message,
-
application: nil
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Opportunities
-
# Service for AI-powered extraction of job opportunity details from recruiter emails
-
#
-
# Uses configured LLM providers to extract structured data like company name,
-
# job role, links, and key details from unstructured email content.
-
# Logs all LLM calls to Ai::LlmApiLog for observability.
-
#
-
# @example
-
# service = Opportunities::ExtractionService.new(opportunity)
-
# result = service.extract
-
# if result[:success]
-
# # Update opportunity with extracted data
-
# end
-
#
-
class ExtractionService < ApplicationService
-
attr_reader :opportunity
-
-
# Initialize the service
-
#
-
# @param opportunity [Opportunity] The opportunity to extract data for
-
def initialize(opportunity)
-
@opportunity = opportunity
-
end
-
-
# Extracts job opportunity data using AI
-
#
-
# @return [Hash] Result with success status and extracted data
-
def extract
-
return { success: false, error: "No email content available" } unless email_content_available?
-
-
# Build prompt with email content
-
prompt = build_prompt
-
-
# Try extraction with LLM providers
-
result = extract_with_llm(prompt)
-
-
if result[:success]
-
# Update the opportunity with extracted data
-
update_opportunity(result[:data])
-
result
-
else
-
{ success: false, error: result[:error] || "Extraction failed" }
-
end
-
rescue StandardError => e
-
notify_error(
-
e,
-
context: "opportunity_extraction",
-
user: opportunity&.user,
-
opportunity_id: opportunity&.id
-
)
-
{ success: false, error: e.message }
-
end
-
-
private
-
-
# Checks if email content is available
-
#
-
# @return [Boolean]
-
def email_content_available?
-
synced_email.present? && (
-
synced_email.body_preview.present? ||
-
synced_email.snippet.present? ||
-
synced_email.subject.present?
-
)
-
end
-
-
# Returns the associated synced email
-
#
-
# @return [SyncedEmail, nil]
-
def synced_email
-
@synced_email ||= opportunity.synced_email
-
end
-
-
# Builds the extraction prompt
-
#
-
# @return [String]
-
def build_prompt
-
subject = synced_email.subject || "(No subject)"
-
body = synced_email.body_preview || synced_email.snippet || ""
-
vars = {
-
subject: subject,
-
body: body.truncate(4000)
-
}
-
-
Ai::PromptBuilderService.new(
-
prompt_class: Ai::EmailExtractionPrompt,
-
variables: vars
-
).run
-
end
-
-
# Extracts data using LLM providers
-
#
-
# @param prompt [String] The extraction prompt
-
# @return [Hash] Result with success and data
-
def extract_with_llm(prompt)
-
prompt_template = Ai::EmailExtractionPrompt.active_prompt
-
system_message = prompt_template&.system_prompt.presence || Ai::EmailExtractionPrompt.default_system_prompt
-
-
runner = Ai::ProviderRunnerService.new(
-
provider_chain: provider_chain,
-
prompt: prompt,
-
content_size: (synced_email.body_preview || synced_email.snippet || "").bytesize,
-
system_message: system_message,
-
provider_for: method(:get_provider_instance),
-
run_options: { max_tokens: 2000, temperature: 0.1 },
-
logger_builder: lambda { |provider_name, provider|
-
Ai::ApiLoggerService.new(
-
operation_type: :email_extraction,
-
loggable: opportunity,
-
provider: provider_name,
-
model: provider.respond_to?(:model_name) ? provider.model_name : "unknown",
-
llm_prompt: prompt_template
-
)
-
},
-
operation: :email_extraction,
-
loggable: opportunity,
-
user: opportunity&.user,
-
error_context: {
-
severity: "warning",
-
opportunity_id: opportunity&.id
-
}
-
)
-
-
result = runner.run do |response|
-
parsed = parse_response(response[:content])
-
log_data = {
-
confidence: parsed&.dig(:confidence_score),
-
company_name: parsed&.dig(:company_name),
-
job_role_title: parsed&.dig(:job_role_title),
-
job_url: parsed&.dig(:job_url)
-
}.compact
-
-
confidence = parsed[:confidence_score].to_f
-
accept = confidence >= 0.5
-
[ parsed, log_data, accept ]
-
end
-
-
return { success: true, data: result[:parsed], provider: result[:provider] } if result[:success]
-
-
{ success: false, error: "All providers failed or returned low confidence" }
-
end
-
-
# Returns the provider chain
-
#
-
# @return [Array<String>]
-
def provider_chain
-
LlmProviders::ProviderConfigHelper.all_providers
-
end
-
-
# Gets a provider instance
-
#
-
# @param provider_name [String]
-
# @return [LlmProviders::BaseProvider, nil]
-
def get_provider_instance(provider_name)
-
case provider_name.to_s.downcase
-
when "openai"
-
LlmProviders::OpenaiProvider.new
-
when "anthropic"
-
LlmProviders::AnthropicProvider.new
-
when "ollama"
-
LlmProviders::OllamaProvider.new
-
else
-
nil
-
end
-
end
-
-
# Parses the LLM response
-
#
-
# @param response_text [String]
-
# @return [Hash]
-
def parse_response(response_text)
-
return { confidence_score: 0.0 } unless response_text.present?
-
-
# Try to extract JSON from the response
-
json_match = response_text.match(/\{.*\}/m)
-
return { confidence_score: 0.0 } unless json_match
-
-
data = JSON.parse(json_match[0])
-
data.deep_symbolize_keys
-
rescue JSON::ParserError
-
{ confidence_score: 0.0 }
-
end
-
-
# Updates the opportunity with extracted data
-
#
-
# @param data [Hash] Extracted data
-
# @return [void]
-
def update_opportunity(data)
-
updates = {}
-
-
# Basic job info
-
updates[:company_name] = data[:company_name] if data[:company_name].present?
-
updates[:job_role_title] = data[:job_role_title] if data[:job_role_title].present?
-
updates[:job_url] = data[:job_url] if data[:job_url].present?
-
-
# Recruiter info
-
if data[:recruiter_info].is_a?(Hash)
-
updates[:recruiter_name] = data[:recruiter_info][:name] if data[:recruiter_info][:name].present?
-
updates[:recruiter_company] = data[:recruiter_info][:company] if data[:recruiter_info][:company].present?
-
end
-
-
# Key details
-
updates[:key_details] = data[:key_details] if data[:key_details].present?
-
-
# Links
-
updates[:extracted_links] = data[:all_links] if data[:all_links].is_a?(Array)
-
-
# Source detection
-
if data[:is_forwarded]
-
updates[:source_type] = case data[:original_source]
-
when "linkedin" then "linkedin_forward"
-
when "referral" then "referral"
-
else "other"
-
end
-
end
-
-
# Store full extraction data including new domain/department fields
-
updates[:extracted_data] = opportunity.extracted_data.merge(
-
raw_extraction: data,
-
extracted_at: Time.current.iso8601,
-
company_domain: data[:company_domain],
-
job_role_department: data[:job_role_department]
-
).compact
-
updates[:ai_confidence_score] = data[:confidence_score]
-
-
opportunity.update!(updates)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
# Service for generating profile insights and statistics
-
class ProfileInsightsService
-
# @param user [User] The user to generate insights for
-
def initialize(user)
-
@user = user
-
end
-
-
# Generates comprehensive insights for the user
-
# @return [Hash] Hash containing all insights
-
def generate_insights
-
{
-
stats: interview_stats,
-
skill_insights: skill_insights,
-
strengths: top_strengths,
-
improvements: areas_to_improve,
-
timeline: learning_timeline,
-
recent_activity: recent_activity
-
}
-
end
-
-
# Skill-related insights from user profile
-
# @return [Hash] Skill statistics and insights
-
def skill_insights
-
user_skills = @user.user_skills.includes(:skill_tag)
-
-
{
-
total: user_skills.count,
-
strong: user_skills.strong_skills.count,
-
moderate: user_skills.moderate_skills.count,
-
developing: user_skills.developing_skills.count,
-
top_skills: user_skills.by_level_desc.limit(5).map { |s| { name: s.skill_name, level: s.aggregated_level.round(1) } },
-
categories: user_skills.group(:category).count.sort_by { |_, v| -v }.first(5).to_h,
-
average_level: user_skills.average(:aggregated_level)&.round(1) || 0,
-
resumes_analyzed: @user.user_resumes.analyzed.count,
-
matching_target_roles: calculate_target_role_match_percentage(user_skills)
-
}
-
end
-
-
private
-
-
# Interview statistics
-
# @return [Hash] Statistics about applications
-
def interview_stats
-
applications = @user.interview_applications
-
-
{
-
total: applications.count,
-
by_stage: InterviewApplication::PIPELINE_STAGES.map { |stage|
-
[ stage, applications.where(pipeline_stage: stage).count ]
-
}.to_h,
-
with_feedback: applications.joins(interview_rounds: :interview_feedback).distinct.count,
-
this_month: applications.where("created_at >= ?", 1.month.ago).count
-
}
-
end
-
-
# Top strengths based on feedback
-
# @return [Array<Hash>] Array of strengths with counts
-
def top_strengths
-
feedback_entries = InterviewFeedback.joins(interview_round: { interview_application: :user })
-
.where(users: { id: @user.id })
-
.where.not(went_well: nil)
-
-
# TODO: Implement actual NLP analysis
-
# For now, return placeholder data based on tags
-
skill_mentions = {}
-
-
feedback_entries.each do |entry|
-
entry.tag_list.each do |tag|
-
skill_mentions[tag] ||= 0
-
skill_mentions[tag] += 1 if entry.went_well.present?
-
end
-
end
-
-
skill_mentions.sort_by { |_k, v| -v }.first(5).map do |skill, count|
-
{ name: skill, count: count }
-
end
-
end
-
-
# Areas to improve based on feedback
-
# @return [Array<Hash>] Array of improvement areas with counts
-
def areas_to_improve
-
feedback_entries = InterviewFeedback.joins(interview_round: { interview_application: :user })
-
.where(users: { id: @user.id })
-
.where.not(to_improve: nil)
-
-
# TODO: Implement actual NLP analysis
-
# For now, return placeholder data
-
skill_mentions = {}
-
-
feedback_entries.each do |entry|
-
entry.tag_list.each do |tag|
-
skill_mentions[tag] ||= 0
-
skill_mentions[tag] += 1 if entry.to_improve.present?
-
end
-
end
-
-
skill_mentions.sort_by { |_k, v| -v }.first(5).map do |skill, count|
-
{ name: skill, count: count }
-
end
-
end
-
-
# Learning timeline showing progress over time
-
# @return [Array<Hash>] Timeline data
-
def learning_timeline
-
applications = @user.interview_applications.order(created_at: :asc).includes(interview_rounds: :interview_feedback)
-
-
applications.map do |application|
-
{
-
date: application.created_at,
-
company: application.company.name,
-
role: application.job_role.title,
-
stage: application.pipeline_stage,
-
has_feedback: application.interview_rounds.joins(:interview_feedback).any?,
-
sentiment: calculate_sentiment(application)
-
}
-
end
-
end
-
-
# Recent activity
-
# @return [Array<Hash>] Recent activities
-
def recent_activity
-
activities = []
-
-
# Recent applications
-
@user.interview_applications.order(created_at: :desc).limit(5).each do |application|
-
activities << {
-
type: :application,
-
date: application.created_at,
-
description: "Added application at #{application.company.name}",
-
icon: :briefcase
-
}
-
end
-
-
# Recent feedback
-
InterviewFeedback.joins(interview_round: { interview_application: :user })
-
.where(users: { id: @user.id })
-
.order(created_at: :desc)
-
.limit(5)
-
.includes(interview_round: { interview_application: :company })
-
.each do |feedback|
-
activities << {
-
type: :feedback,
-
date: feedback.created_at,
-
description: "Added feedback for #{feedback.interview_round.interview_application.company.name}",
-
icon: :document
-
}
-
end
-
-
activities.sort_by { |a| a[:date] }.reverse.first(10)
-
end
-
-
# Calculate sentiment of application based on feedback
-
# @param application [InterviewApplication] The application to analyze
-
# @return [String] positive, neutral, or negative
-
def calculate_sentiment(application)
-
feedbacks = application.interview_rounds.joins(:interview_feedback).map(&:interview_feedback)
-
return "neutral" unless feedbacks.any?
-
-
# TODO: Implement actual sentiment analysis
-
latest_feedback = feedbacks.sort_by(&:created_at).last
-
-
if latest_feedback.went_well.present? && latest_feedback.to_improve.blank?
-
"positive"
-
elsif latest_feedback.went_well.blank? && latest_feedback.to_improve.present?
-
"negative"
-
else
-
"neutral"
-
end
-
end
-
-
# Calculate percentage of skills matching target roles
-
# @param user_skills [ActiveRecord::Relation] User skills relation
-
# @return [Integer] Percentage of matching skills (0-100)
-
def calculate_target_role_match_percentage(user_skills)
-
target_roles = @user.target_job_roles
-
return 0 if target_roles.empty? || user_skills.empty?
-
-
# Get required skills from target roles via application skill tags
-
target_role_skill_ids = ApplicationSkillTag.joins(:interview_application)
-
.where(interview_applications: { job_role_id: target_roles.pluck(:id) })
-
.distinct
-
.pluck(:skill_tag_id)
-
-
return 0 if target_role_skill_ids.empty?
-
-
# Calculate overlap
-
user_skill_ids = user_skills.pluck(:skill_tag_id)
-
matching_skills = (user_skill_ids & target_role_skill_ids).count
-
-
((matching_skills.to_f / target_role_skill_ids.count) * 100).round
-
end
-
end
-
# frozen_string_literal: true
-
-
require "timeout"
-
-
# Service for quick application creation from URL
-
#
-
# Orchestrates the entire quick apply flow:
-
# 1. Creates JobListing with URL
-
# 2. Runs extraction to get job details
-
# 3. Extracts company name and job role title
-
# 4. Creates/finds Company and JobRole
-
# 5. Updates JobListing with all data
-
# 6. Creates InterviewApplication
-
#
-
# @example
-
# service = QuickApplyFromUrlService.new("https://boards.greenhouse.io/stripe/jobs/123", user)
-
# result = service.call
-
# if result[:success]
-
# application = result[:application]
-
# end
-
class QuickApplyFromUrlService
-
EXTRACTION_TIMEOUT = 15.seconds
-
-
# Initialize the service with URL and user
-
#
-
# @param [String] url The job listing URL
-
# @param [User] user The user creating the application
-
def initialize(url, user)
-
@url = url
-
@normalized_url = ScrapedJobListingData.normalize_url(url)
-
@user = user
-
@start_time = Time.current
-
end
-
-
# Executes the quick apply flow
-
#
-
# @return [Hash] Result hash with success status, application, and errors
-
def call
-
return error_result("URL is required") if @url.blank?
-
return error_result("Invalid URL format") unless valid_url?
-
-
# Extract company name from URL first (needed to create JobListing)
-
company_name = extract_company_name_from_url || extract_company_name_from_domain
-
job_role_title = "Unknown Position" # Placeholder, will be updated after extraction
-
-
# Find or create Company and JobRole (with placeholder values)
-
company = find_or_create_company(company_name)
-
job_role = find_or_create_job_role(job_role_title)
-
-
# Create JobListing with company and job_role (required for validations)
-
job_listing = create_job_listing(company, job_role)
-
-
# Run extraction synchronously
-
# The orchestrator will extract and update company and job_role on the job listing
-
run_extraction(job_listing)
-
-
# After extraction, use the company and job_role that were set by the orchestrator
-
# The orchestrator already extracted and updated these fields correctly
-
job_listing.reload
-
company = job_listing.company
-
job_role = job_listing.job_role
-
-
# Create InterviewApplication using the company and job_role from the job listing
-
application = create_application(job_listing, company, job_role)
-
-
{
-
success: true,
-
application: application,
-
job_listing: job_listing,
-
company: company,
-
job_role: job_role,
-
extraction_time: Time.current - @start_time
-
}
-
rescue => e
-
Rails.logger.error("QuickApplyFromUrlService failed: #{e.message}")
-
Rails.logger.error(e.backtrace.join("\n"))
-
error_result(e.message)
-
end
-
-
private
-
-
# Validates URL format
-
#
-
# @return [Boolean] True if URL is valid
-
def valid_url?
-
uri = URI.parse(@url)
-
uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
-
rescue URI::InvalidURIError
-
false
-
end
-
-
# Creates a JobListing with URL, company, and job_role
-
#
-
# @param [Company] company The company
-
# @param [JobRole] job_role The job role
-
# @return [JobListing] The created job listing
-
def create_job_listing(company, job_role)
-
job_listing = JobListing.find_or_initialize_by(url: @normalized_url)
-
-
# Set attributes if it's a new record
-
if job_listing.new_record?
-
job_listing.company = company
-
job_listing.job_role = job_role
-
job_listing.status = :active
-
job_listing.source_id = extract_source_id(@normalized_url)
-
job_listing.custom_sections = (job_listing.custom_sections || {}).merge("original_url" => @url)
-
job_listing.save!
-
end
-
-
job_listing
-
end
-
-
# Runs extraction synchronously with timeout
-
#
-
# @param [JobListing] job_listing The job listing to extract for
-
# @return [Hash] Result hash with success status and extracted data
-
def run_extraction(job_listing)
-
# Use orchestrator service for extraction
-
orchestrator = Scraping::OrchestratorService.new(job_listing)
-
-
# Run with timeout - but use a thread so we don't interrupt the orchestrator
-
# This allows the extraction to continue even if we timeout waiting for it
-
extraction_thread = Thread.new do
-
Thread.current[:result] = orchestrator.call
-
end
-
-
# Wait for completion with timeout
-
completed = extraction_thread.join(EXTRACTION_TIMEOUT)
-
-
if completed
-
success = extraction_thread[:result]
-
job_listing.reload
-
-
if success && job_listing.extraction_completed?
-
{
-
success: true,
-
data: {}
-
}
-
else
-
{
-
success: false,
-
error: "Extraction failed"
-
}
-
end
-
else
-
# Timeout waiting for extraction - it's still running in the background thread
-
# Queue a background job to handle completion/retry
-
handle_extraction_timeout(job_listing)
-
end
-
rescue => e
-
Rails.logger.error("Extraction error: #{e.message}")
-
Rails.logger.error(e.backtrace.join("\n"))
-
{
-
success: false,
-
error: e.message
-
}
-
end
-
-
# Handles timeout by queuing background job if needed
-
#
-
# @param job_listing [JobListing] The job listing being extracted
-
# @return [Hash] Result hash
-
def handle_extraction_timeout(job_listing)
-
latest_attempt = job_listing.scraping_attempts.order(created_at: :desc).first
-
-
# Always queue a background job to monitor/complete the extraction
-
# The extraction might still be running, but if it fails, the job will handle it
-
if latest_attempt
-
# Queue with delay to give the current extraction time to complete
-
ScrapeJobListingJob.set(wait: 30.seconds).perform_later(job_listing)
-
-
Rails.logger.info({
-
event: "extraction_timeout_job_queued",
-
job_listing_id: job_listing.id,
-
scraping_attempt_id: latest_attempt.id,
-
attempt_status: latest_attempt.status
-
}.to_json)
-
end
-
-
{
-
success: false,
-
error: "Extraction is taking longer than expected. Processing in background..."
-
}
-
end
-
-
# Extracts company name from various sources
-
#
-
# @param [JobListing] job_listing The job listing
-
# @param [Hash] extracted_data The extracted data
-
# @return [String] Company name
-
def extract_company_name(job_listing, extracted_data)
-
# Try company slug from URL first
-
company_name = extract_company_name_from_url
-
-
# If we have extracted data with company field, use that
-
company_name = extracted_data[:company] if extracted_data[:company].present?
-
-
# Fallback to domain name
-
company_name ||= extract_company_name_from_domain
-
-
normalize_company_name(company_name)
-
end
-
-
# Extracts company name from URL patterns
-
#
-
# @return [String, nil] Company name or nil
-
def extract_company_name_from_url
-
detector = Scraping::JobBoardDetectorService.new(@url)
-
company_slug = detector.company_slug
-
-
return nil unless company_slug
-
-
# Convert slug to readable name
-
# e.g., "stripe" -> "Stripe", "acme-corp" -> "Acme Corp"
-
company_slug
-
.gsub(/[-_]/, " ")
-
.split
-
.map(&:capitalize)
-
.join(" ")
-
end
-
-
# Extracts company name from domain
-
#
-
# @return [String] Company name from domain
-
def extract_company_name_from_domain
-
uri = URI.parse(@url)
-
domain = uri.host
-
-
# Remove www. prefix
-
domain = domain.sub(/^www\./, "")
-
-
# Extract base domain (e.g., "stripe.com" -> "stripe")
-
base = domain.split(".").first
-
-
# Convert to readable name
-
base.gsub(/[-_]/, " ").split.map(&:capitalize).join(" ")
-
rescue
-
"Unknown Company"
-
end
-
-
# Extracts company from scraped data if available
-
#
-
# @param [JobListing] job_listing The job listing
-
# @return [String, nil] Company name or nil
-
def extract_company_from_scraped_data(job_listing)
-
# Check if company name is in custom_sections or scraped_data
-
job_listing.custom_sections&.dig("company") ||
-
job_listing.scraped_data&.dig("company")
-
end
-
-
# Extracts job role title from scraped data
-
#
-
# @param [JobListing] job_listing The job listing
-
# @param [Hash] extracted_data The extracted data
-
# @return [String] Job role title
-
def extract_job_role_title(job_listing, extracted_data)
-
title = extracted_data[:title] || job_listing.title
-
-
return "Unknown Position" if title.blank?
-
-
normalize_job_role_title(title)
-
end
-
-
# Normalizes company name for matching
-
#
-
# @param [String] name The company name
-
# @return [String] Normalized name
-
def normalize_company_name(name)
-
return "Unknown Company" if name.blank?
-
-
name.strip.titleize
-
end
-
-
# Normalizes job role title for matching
-
#
-
# @param [String] title The job role title
-
# @return [String] Normalized title
-
def normalize_job_role_title(title)
-
return "Unknown Position" if title.blank?
-
-
title.strip
-
end
-
-
# Finds or creates a Company
-
#
-
# @param [String] name The company name
-
# @return [Company] The company record
-
def find_or_create_company(name)
-
normalized_name = normalize_company_name(name)
-
-
Company.find_or_create_by(name: normalized_name) do |company|
-
# Extract website from URL if possible
-
uri = URI.parse(@url)
-
company.website = "#{uri.scheme}://#{uri.host}" if uri.host
-
end
-
end
-
-
# Finds or creates a JobRole
-
#
-
# @param [String] title The job role title
-
# @return [JobRole] The job role record
-
def find_or_create_job_role(title)
-
normalized_title = normalize_job_role_title(title)
-
-
JobRole.find_or_create_by(title: normalized_title)
-
end
-
-
# Creates an InterviewApplication
-
#
-
# @param [JobListing] job_listing The job listing
-
# @param [Company] company The company
-
# @param [JobRole] job_role The job role
-
# @return [InterviewApplication] The created application
-
def create_application(job_listing, company, job_role)
-
application = @user.interview_applications.find_or_initialize_by(job_listing: job_listing)
-
application.company = company
-
application.job_role = job_role
-
application.applied_at ||= Date.today
-
application.save!
-
application
-
end
-
-
# Extracts source ID from URL
-
#
-
# @param [String] url The URL
-
# @return [String, nil] Source ID or nil
-
def extract_source_id(url)
-
match = url.match(%r{/(jobs?|careers?|positions?)/([^/\?]+)})
-
match ? match[2] : nil
-
end
-
-
# Returns an error result hash
-
#
-
# @param [String] error_message The error message
-
# @return [Hash] Error result hash
-
def error_result(error_message)
-
{
-
success: false,
-
error: error_message,
-
application: nil
-
}
-
end
-
end
-
# frozen_string_literal: true
-
-
module Resumes
-
# Service for AI-powered skill extraction from resume text
-
#
-
# Uses configured LLM providers to extract structured skill data,
-
# with automatic fallback to alternative providers on failure.
-
# Logs all LLM calls to Ai::LlmApiLog for observability.
-
#
-
# @example
-
# extractor = Resumes::AiSkillExtractorService.new(user_resume)
-
# result = extractor.extract
-
# if result[:success]
-
# result[:skills].each do |skill|
-
# puts "#{skill[:name]}: #{skill[:proficiency]}/5"
-
# end
-
# end
-
#
-
class AiSkillExtractorService < ApplicationService
-
attr_reader :user_resume
-
-
# Minimum confidence threshold to accept extraction
-
MIN_CONFIDENCE = 0.6
-
-
# Initialize the service
-
#
-
# @param user_resume [UserResume] The resume to analyze
-
def initialize(user_resume)
-
@user_resume = user_resume
-
end
-
-
# Extracts skills from the resume text using AI
-
#
-
# @return [Hash] Result with :success, :skills, :summary, :confidence keys
-
def extract
-
text = user_resume.parsed_text
-
return error_result("No parsed text available") if text.blank?
-
-
prompt = build_extraction_prompt(text)
-
result = extract_with_providers(prompt, text.bytesize)
-
-
return error_result(result[:error]) unless result[:success]
-
-
existing_extracted_data = coerce_extracted_data_hash(user_resume.extracted_data)
-
-
# Store structured extraction output for traceability and downstream profile features.
-
#
-
# Keep both:
-
# - parsed: normalized, structured output used by the app
-
# - raw_response: original assistant text (best-effort, truncated by DB logger elsewhere)
-
user_resume.update!(
-
extracted_data: existing_extracted_data.merge(
-
"resume_extraction" => {
-
"extracted_at" => Time.current.iso8601,
-
"parsed" => {
-
"skills" => result[:skills],
-
"work_history" => result[:work_history],
-
"summary" => result[:summary],
-
"overall_confidence" => result[:confidence],
-
"strengths" => result[:strengths],
-
"domains" => result[:domains],
-
"resume_date" => result[:resume_date],
-
"resume_date_confidence" => result[:resume_date_confidence],
-
"resume_date_source" => result[:resume_date_source]
-
},
-
"raw_response" => result[:raw_response].to_s.truncate(50_000)
-
}
-
)
-
)
-
-
success_result(result)
-
rescue StandardError => e
-
notify_error(
-
e,
-
context: "resume_skill_extraction",
-
user: user_resume&.user,
-
user_resume_id: user_resume&.id
-
)
-
error_result(e.message)
-
end
-
-
private
-
-
# Extracts using provider chain with fallback
-
#
-
# @param prompt [String] The extraction prompt
-
# @param content_size [Integer] Size of resume text in bytes
-
# @return [Hash] Extraction result
-
def extract_with_providers(prompt, content_size)
-
prompt_template = Ai::ResumeSkillExtractionPrompt.active_prompt
-
system_message = prompt_template&.system_prompt.presence || Ai::ResumeSkillExtractionPrompt.default_system_prompt
-
-
runner = Ai::ProviderRunnerService.new(
-
provider_chain: provider_chain,
-
prompt: prompt,
-
content_size: content_size,
-
system_message: system_message,
-
provider_for: method(:get_provider_instance),
-
logger_builder: lambda { |provider_name, provider|
-
Ai::ApiLoggerService.new(
-
operation_type: :resume_extraction,
-
loggable: user_resume,
-
provider: provider_name,
-
model: provider.respond_to?(:model_name) ? provider.model_name : "unknown",
-
llm_prompt: prompt_template
-
)
-
},
-
operation: :resume_extraction,
-
loggable: user_resume,
-
user: user_resume&.user,
-
error_context: {
-
severity: "warning",
-
user_resume_id: user_resume&.id
-
}
-
)
-
-
result = runner.run do |response|
-
parsed = parse_response(response[:content])
-
log_data = {
-
confidence: parsed&.dig(:confidence)
-
}
-
-
if parsed.present? && parsed[:skills].present?
-
log_data.merge!(
-
skills: parsed[:skills],
-
summary: parsed[:summary],
-
strengths: parsed[:strengths],
-
domains: parsed[:domains]
-
)
-
end
-
-
confidence = parsed[:confidence].to_f
-
accept = confidence >= MIN_CONFIDENCE
-
[ parsed, log_data, accept ]
-
end
-
-
return { success: false, error: "All providers failed or returned low confidence" } unless result[:success]
-
-
raw_response = result.dig(:result, :raw_response)
-
result[:parsed].merge(
-
success: true,
-
provider: result[:provider],
-
model: result[:model],
-
raw_response: raw_response
-
)
-
end
-
-
# Builds the extraction prompt
-
#
-
# @param text [String] Resume text
-
# @return [String] Complete prompt
-
def build_extraction_prompt(text)
-
prompt_template = Ai::ResumeSkillExtractionPrompt.active_prompt
-
-
if prompt_template
-
prompt_template.build_prompt(resume_text: text.truncate(15000))
-
else
-
Ai::ResumeSkillExtractionPrompt.default_prompt_template
-
.gsub("{{resume_text}}", text.truncate(15000))
-
end
-
end
-
-
# Parses the AI response
-
#
-
# @param response_text [String] Raw AI response
-
# @return [Hash] Parsed data
-
def parse_response(response_text)
-
return { skills: [], error: "No response" } unless response_text.present?
-
-
data = extract_json_object(response_text)
-
return { skills: [], error: "No JSON found in response" } unless data
-
-
normalize_parsed_data(data)
-
rescue JSON::ParserError => e
-
Rails.logger.error("Failed to parse skill extraction response: #{e.message}")
-
{ skills: [], error: "Invalid JSON response" }
-
end
-
-
# Coerces extracted_data into a Hash.
-
#
-
# Older records may have extracted_data stored as a JSON string in a jsonb column.
-
#
-
# @param value [Object]
-
# @return [Hash]
-
def coerce_extracted_data_hash(value)
-
return {} if value.blank?
-
return value if value.is_a?(Hash)
-
-
if value.is_a?(String)
-
parsed = (JSON.parse(value) rescue nil)
-
return parsed if parsed.is_a?(Hash)
-
end
-
-
{}
-
end
-
-
# Extracts a JSON object from an LLM response string.
-
#
-
# Handles:
-
# - raw JSON
-
# - markdown fenced blocks: ```json { ... } ```
-
# - extra prose around JSON
-
#
-
# @param text [String]
-
# @return [Hash, nil]
-
def extract_json_object(text)
-
str = text.to_s
-
-
fenced = str.match(/```json\s*(\{.*?\})\s*```/m)
-
if fenced
-
parsed = (JSON.parse(fenced[1]) rescue nil)
-
return parsed if parsed.is_a?(Hash)
-
end
-
-
start_idx = str.index("{")
-
end_idx = str.rindex("}")
-
return nil if start_idx.nil? || end_idx.nil? || end_idx <= start_idx
-
-
candidate = str[start_idx..end_idx]
-
parsed = (JSON.parse(candidate) rescue nil)
-
parsed.is_a?(Hash) ? parsed : nil
-
end
-
-
# Normalizes parsed data
-
#
-
# @param data [Hash] Raw parsed data
-
# @return [Hash] Normalized data
-
def normalize_parsed_data(data)
-
skills = (data["skills"] || []).map do |skill|
-
{
-
name: skill["name"]&.strip,
-
category: normalize_category(skill["category"]),
-
proficiency: skill["proficiency"]&.to_i&.clamp(1, 5) || 3,
-
confidence: skill["confidence"]&.to_f&.clamp(0.0, 1.0) || 0.5,
-
evidence: skill["evidence"]&.truncate(500),
-
years: skill["years"]&.to_i
-
}
-
end.reject { |s| s[:name].blank? }
-
-
work_history = Array(data["work_history"]).map do |entry|
-
normalize_work_history_entry(entry)
-
end.compact
-
-
{
-
skills: skills,
-
work_history: work_history,
-
summary: data["summary"],
-
confidence: data["overall_confidence"]&.to_f || 0.5,
-
strengths: Array(data["strengths"]),
-
domains: Array(data["domains"]),
-
resume_date: parse_resume_date(data["resume_date"]),
-
resume_date_confidence: data["resume_date_confidence"],
-
resume_date_source: data["resume_date_source"]
-
}
-
end
-
-
# Normalizes an extracted work history entry.
-
#
-
# Supports both legacy (company/role/duration) and expanded schema:
-
# start_date/end_date/current/responsibilities/highlights/skills_used/company_domain/role_department.
-
#
-
# @param entry [Hash]
-
# @return [Hash, nil]
-
def normalize_work_history_entry(entry)
-
return nil unless entry.is_a?(Hash)
-
-
company = (entry["company"] || entry[:company]).to_s.strip
-
role = (entry["role"] || entry["title"] || entry[:role] || entry[:title]).to_s.strip
-
duration_text = (entry["duration"] || entry[:duration]).to_s.strip
-
-
# New fields for domain and department
-
company_domain = (entry["company_domain"] || entry[:company_domain]).to_s.strip.presence
-
role_department = normalize_department(entry["role_department"] || entry[:role_department])
-
-
start_date = parse_flexible_date(entry["start_date"] || entry[:start_date] || entry["start"] || entry[:start])
-
end_date = parse_flexible_date(entry["end_date"] || entry[:end_date] || entry["end"] || entry[:end])
-
current =
-
if entry.key?("current") || entry.key?(:current)
-
!!(entry["current"] || entry[:current])
-
else
-
false
-
end
-
-
responsibilities = normalize_text_array(entry["responsibilities"] || entry[:responsibilities] || entry["responsibility"] || entry[:responsibility])
-
highlights = normalize_text_array(entry["highlights"] || entry[:highlights] || entry["achievements"] || entry[:achievements])
-
-
skills_used = normalize_skill_refs(
-
entry["skills_used"] || entry[:skills_used] ||
-
entry["skills"] || entry[:skills] ||
-
entry["technologies"] || entry[:technologies]
-
)
-
-
normalized = {
-
company: company.presence,
-
company_domain: company_domain,
-
role: role.presence,
-
role_department: role_department,
-
duration: duration_text.presence,
-
start_date: start_date,
-
end_date: end_date,
-
current: current,
-
responsibilities: responsibilities,
-
highlights: highlights,
-
skills_used: skills_used
-
}.compact
-
-
return nil if normalized.except(:responsibilities, :highlights, :skills_used, :current, :company_domain, :role_department).blank?
-
-
# Always include these arrays/flags for consistent downstream usage.
-
normalized[:responsibilities] ||= []
-
normalized[:highlights] ||= []
-
normalized[:skills_used] ||= []
-
normalized[:current] = !!normalized[:current]
-
normalized
-
end
-
-
# Normalizes department name to match our valid departments
-
#
-
# @param department [String, nil]
-
# @return [String, nil]
-
def normalize_department(department)
-
return nil if department.blank?
-
-
dept = department.to_s.strip
-
-
valid_departments = [
-
"Engineering", "Product", "Design", "Data Science", "DevOps/SRE",
-
"Sales", "Marketing", "Customer Success", "Finance", "HR/People",
-
"Legal", "Operations", "Executive", "Research", "QA/Testing",
-
"Security", "IT", "Content", "Other"
-
]
-
-
# Exact match
-
return dept if valid_departments.include?(dept)
-
-
# Case-insensitive match
-
matched = valid_departments.find { |d| d.downcase == dept.downcase }
-
return matched if matched
-
-
# Partial match for common variations
-
dept_lower = dept.downcase
-
return "Engineering" if dept_lower.include?("engineer") || dept_lower.include?("develop") || dept_lower.include?("tech")
-
return "Product" if dept_lower.include?("product")
-
return "Design" if dept_lower.include?("design") || dept_lower.include?("ux") || dept_lower.include?("ui")
-
return "Data Science" if dept_lower.include?("data") || dept_lower.include?("analyt")
-
return "DevOps/SRE" if dept_lower.include?("devops") || dept_lower.include?("sre") || dept_lower.include?("infrastructure")
-
return "Sales" if dept_lower.include?("sales")
-
return "Marketing" if dept_lower.include?("market") || dept_lower.include?("growth")
-
return "HR/People" if dept_lower.include?("hr") || dept_lower.include?("human") || dept_lower.include?("people") || dept_lower.include?("talent")
-
return "Finance" if dept_lower.include?("finance") || dept_lower.include?("account")
-
return "Legal" if dept_lower.include?("legal")
-
return "Executive" if dept_lower.include?("executive") || dept_lower.include?("leadership") || dept_lower.include?("c-suite")
-
return "QA/Testing" if dept_lower.include?("qa") || dept_lower.include?("quality") || dept_lower.include?("test")
-
return "Security" if dept_lower.include?("security")
-
return "Customer Success" if dept_lower.include?("customer") || dept_lower.include?("support")
-
-
nil
-
end
-
-
# Parses flexible date formats (YYYY-MM-DD, YYYY-MM, YYYY).
-
#
-
# @param value [String, nil]
-
# @return [Date, nil]
-
def parse_flexible_date(value)
-
str = value.to_s.strip
-
return nil if str.blank?
-
-
if str.match?(/\A\d{4}-\d{2}-\d{2}\z/)
-
Date.parse(str)
-
elsif str.match?(/\A\d{4}-\d{2}\z/)
-
year, month = str.split("-").map(&:to_i)
-
Date.new(year, month, 1)
-
elsif str.match?(/\A\d{4}\z/)
-
Date.new(str.to_i, 1, 1)
-
else
-
Date.parse(str)
-
end
-
rescue ArgumentError
-
nil
-
end
-
-
# Normalizes a value into an array of non-empty strings.
-
#
-
# @param value [Object]
-
# @return [Array<String>]
-
def normalize_text_array(value)
-
Array(value).map { |v| v.to_s.strip }.reject(&:blank?).first(50)
-
end
-
-
# Normalizes a “skills used” payload into a list of hashes.
-
#
-
# Supports:
-
# - ["Ruby", "Postgres"]
-
# - [{ "name": "Ruby", "evidence": "...", "confidence": 0.8 }, ...]
-
#
-
# @param value [Object]
-
# @return [Array<Hash>]
-
def normalize_skill_refs(value)
-
Array(value).map do |item|
-
if item.is_a?(Hash)
-
name = (item["name"] || item[:name] || item["skill"] || item[:skill]).to_s.strip
-
next nil if name.blank?
-
-
{
-
name: name,
-
confidence: (item["confidence"] || item[:confidence])&.to_f,
-
evidence: (item["evidence"] || item[:evidence]).to_s.strip.presence
-
}.compact
-
else
-
name = item.to_s.strip
-
next nil if name.blank?
-
{ name: name }
-
end
-
end.compact.uniq { |h| h[:name].to_s.downcase }.first(50)
-
end
-
-
# Parses resume date string to Date object
-
#
-
# @param date_string [String, nil] Date in YYYY-MM-DD format
-
# @return [Date, nil] Parsed date or nil
-
def parse_resume_date(date_string)
-
return nil if date_string.blank?
-
-
Date.parse(date_string)
-
rescue ArgumentError
-
nil
-
end
-
-
# Normalizes category to valid option
-
#
-
# @param category [String] Raw category
-
# @return [String] Normalized category
-
def normalize_category(category)
-
valid_categories = ResumeSkill::CATEGORIES
-
return "Other" if category.blank?
-
-
# Try exact match first
-
return category if valid_categories.include?(category)
-
-
# Try case-insensitive match
-
match = valid_categories.find { |c| c.downcase == category.downcase }
-
return match if match
-
-
# Default to Other
-
"Other"
-
end
-
-
# Returns the provider chain
-
#
-
# @return [Array<String>] Provider names in priority order
-
def provider_chain
-
LlmProviders::ProviderConfigHelper.all_providers
-
end
-
-
# Gets a provider instance
-
#
-
# @param provider_name [String] Provider name
-
# @return [LlmProviders::BaseProvider] Provider instance
-
def get_provider_instance(provider_name)
-
case provider_name.to_s.downcase
-
when "openai" then LlmProviders::OpenaiProvider.new
-
when "anthropic" then LlmProviders::AnthropicProvider.new
-
when "ollama" then LlmProviders::OllamaProvider.new
-
else raise ArgumentError, "Unknown provider: #{provider_name}"
-
end
-
end
-
-
# Builds success result
-
#
-
# @param result [Hash] Extraction result
-
# @return [Hash]
-
def success_result(result)
-
{
-
success: true,
-
skills: result[:skills],
-
work_history: result[:work_history] || [],
-
summary: result[:summary],
-
confidence: result[:confidence],
-
strengths: result[:strengths],
-
domains: result[:domains],
-
resume_date: result[:resume_date],
-
resume_date_confidence: result[:resume_date_confidence],
-
resume_date_source: result[:resume_date_source],
-
provider: result[:provider],
-
model: result[:model]
-
}
-
end
-
-
# Builds error result
-
#
-
# @param message [String] Error message
-
# @return [Hash]
-
def error_result(message)
-
{
-
success: false,
-
error: message,
-
skills: []
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Resumes
-
# Service for orchestrating the complete resume analysis pipeline
-
#
-
# Pipeline steps:
-
# 1. Extract text from uploaded file (PDF/DOCX/DOC/TXT)
-
# 2. Send text to AI for skill extraction
-
# 3. Create ResumeSkill records from extracted skills
-
# 4. Trigger UserSkill aggregation
-
#
-
# @example
-
# service = Resumes::AnalysisService.new(user_resume)
-
# result = service.run
-
# if result[:success]
-
# puts "Extracted #{result[:skills_count]} skills"
-
# end
-
#
-
class AnalysisService
-
attr_reader :user_resume
-
-
# Initialize the service
-
#
-
# @param user_resume [UserResume] The resume to analyze
-
def initialize(user_resume)
-
@user_resume = user_resume
-
end
-
-
# Runs the complete analysis pipeline
-
#
-
# @return [Hash] Result with :success, :skills_count, :error keys
-
def run
-
user_resume.start_analysis!
-
-
# Step 1: Extract text from file
-
text_result = extract_text
-
return failure_result(text_result[:error]) unless text_result[:success]
-
-
# Step 2: Extract skills using AI
-
skill_result = extract_skills
-
return failure_result(skill_result[:error]) unless skill_result[:success]
-
-
# Step 3: Create resume skill records
-
skills_created = create_resume_skills(skill_result[:skills])
-
-
# Step 4: Persist expanded work history (experiences + per-experience skills)
-
persist_work_history(skill_result[:work_history])
-
-
# Step 5: Save resume date if extracted
-
save_resume_date(skill_result)
-
-
# Step 6: Aggregate user skills
-
aggregate_user_skills
-
-
# Step 7: Merge work history across resumes into a user-level profile
-
Resumes::WorkHistoryAggregationService.new(user_resume.user).run
-
-
# Persist resume-derived strengths/domains for later display.
-
persist_strengths_and_domains(skill_result)
-
-
# Mark analysis as complete
-
user_resume.complete_analysis!(summary: skill_result[:summary])
-
-
success_result(skills_created, skill_result)
-
rescue StandardError => e
-
Rails.logger.error("Resume analysis failed: #{e.message}\n#{e.backtrace.first(10).join("\n")}")
-
user_resume.fail_analysis!(error_message: e.message)
-
failure_result(e.message)
-
end
-
-
private
-
-
# Step 1: Extract text from the uploaded file
-
#
-
# @return [Hash] Extraction result
-
def extract_text
-
TextExtractorService.new(user_resume).extract
-
end
-
-
# Step 2: Extract skills using AI
-
#
-
# @return [Hash] AI extraction result
-
def extract_skills
-
AiSkillExtractorService.new(user_resume).extract
-
end
-
-
# Step 3: Create ResumeSkill records from extracted skills
-
#
-
# @param skills [Array<Hash>] Extracted skill data
-
# @return [Integer] Number of skills created
-
def create_resume_skills(skills)
-
return 0 if skills.blank?
-
-
created_count = 0
-
-
skills.each do |skill_data|
-
skill_tag = find_or_create_skill_tag(skill_data[:name])
-
next unless skill_tag
-
-
resume_skill = user_resume.resume_skills.find_or_initialize_by(skill_tag: skill_tag)
-
resume_skill.assign_attributes(
-
model_level: skill_data[:proficiency],
-
confidence_score: skill_data[:confidence],
-
category: skill_data[:category],
-
evidence_snippet: skill_data[:evidence],
-
years_of_experience: skill_data[:years]
-
)
-
-
if resume_skill.save
-
created_count += 1
-
else
-
Rails.logger.warn("Failed to create resume skill: #{resume_skill.errors.full_messages.join(", ")}")
-
end
-
end
-
-
created_count
-
end
-
-
# Finds or creates a skill tag with normalization
-
#
-
# @param name [String] Skill name
-
# @return [SkillTag, nil] The skill tag or nil if invalid
-
def find_or_create_skill_tag(name)
-
return nil if name.blank?
-
-
SkillTag.find_or_create_by_name(name)
-
rescue ActiveRecord::RecordInvalid => e
-
Rails.logger.warn("Failed to create skill tag '#{name}': #{e.message}")
-
nil
-
end
-
-
# Persists expanded work history into normalized tables.
-
#
-
# @param work_history [Array<Hash>] Work history entries
-
# @return [void]
-
def persist_work_history(work_history)
-
return if work_history.blank?
-
-
user_resume.resume_work_experiences.destroy_all
-
-
work_history.each do |entry|
-
next unless entry.is_a?(Hash)
-
-
company_name = entry[:company].to_s.strip
-
role_title = entry[:role].to_s.strip
-
company_domain = entry[:company_domain].to_s.strip.presence
-
role_department = entry[:role_department].to_s.strip.presence
-
-
normalized_company = normalize_company_name(company_name)
-
normalized_role = normalize_job_title(role_title)
-
-
company = normalized_company.present? ? Company.find_or_create_by(name: normalized_company) : nil
-
job_role = find_or_create_job_role_with_department(normalized_role, role_department)
-
-
experience = user_resume.resume_work_experiences.create!(
-
company: company,
-
job_role: job_role,
-
company_name: company_name.presence,
-
role_title: role_title.presence,
-
start_date: entry[:start_date],
-
end_date: entry[:end_date],
-
current: !!entry[:current],
-
duration_text: entry[:duration],
-
responsibilities: Array(entry[:responsibilities]),
-
highlights: Array(entry[:highlights]),
-
metadata: { company_domain: company_domain, role_department: role_department }.compact
-
)
-
-
Array(entry[:skills_used]).each do |skill_ref|
-
next unless skill_ref.is_a?(Hash)
-
-
name = skill_ref[:name].to_s.strip
-
next if name.blank?
-
-
skill_tag = SkillTag.find_or_create_by_name(name)
-
experience.resume_work_experience_skills.find_or_create_by!(skill_tag: skill_tag) do |row|
-
row.confidence_score = skill_ref[:confidence]
-
row.evidence_snippet = skill_ref[:evidence]
-
end
-
end
-
end
-
rescue StandardError => e
-
Rails.logger.warn("Failed to persist work history: #{e.message}")
-
end
-
-
# Finds or creates a job role and assigns department if provided
-
#
-
# @param title [String] Job role title
-
# @param department_name [String, nil] Department name
-
# @return [JobRole, nil]
-
def find_or_create_job_role_with_department(title, department_name)
-
return nil if title.blank?
-
-
job_role = JobRole.find_or_create_by(title: title)
-
-
# Assign department if provided and role doesn't have one
-
if department_name.present? && job_role.category_id.nil?
-
department = Category.find_by(name: department_name, kind: :job_role)
-
job_role.update(category: department) if department
-
elsif job_role.category_id.nil?
-
# Try to infer department from title
-
department = infer_department_from_title(title)
-
job_role.update(category: department) if department
-
end
-
-
job_role
-
end
-
-
# Infers department from job role title using keyword matching
-
#
-
# @param title [String] Job role title
-
# @return [Category, nil]
-
def infer_department_from_title(title)
-
return nil if title.blank?
-
-
title_lower = title.downcase
-
-
department_keywords = {
-
"Engineering" => %w[engineer developer software backend frontend fullstack architect sre devops platform],
-
"Product" => %w[product owner manager pm],
-
"Design" => %w[designer ux ui visual graphic],
-
"Data Science" => %w[data scientist analyst analytics machine learning ml ai],
-
"DevOps/SRE" => %w[devops sre infrastructure reliability platform],
-
"Sales" => %w[sales account executive ae sdr bdr],
-
"Marketing" => %w[marketing growth seo sem content brand],
-
"Customer Success" => %w[customer success support cx],
-
"Finance" => %w[finance accounting financial controller cfo],
-
"HR/People" => %w[hr human resources people talent recruiter recruiting],
-
"Legal" => %w[legal counsel attorney compliance],
-
"Operations" => %w[operations ops logistics supply],
-
"Executive" => %w[ceo cto coo cfo cmo chief director vp president],
-
"Research" => %w[research scientist r&d],
-
"QA/Testing" => %w[qa quality assurance test tester sdet],
-
"Security" => %w[security infosec appsec cyber],
-
"IT" => %w[it helpdesk administrator admin sysadmin],
-
"Content" => %w[content writer editor copywriter]
-
}
-
-
department_keywords.each do |dept_name, keywords|
-
if keywords.any? { |kw| title_lower.include?(kw) }
-
return Category.find_by(name: dept_name, kind: :job_role)
-
end
-
end
-
-
nil
-
end
-
-
# Normalizes company name
-
#
-
# @param name [String] Raw company name
-
# @return [String, nil] Normalized name or nil if invalid
-
def normalize_company_name(name)
-
return nil if name.blank?
-
-
# Remove common suffixes and clean up
-
normalized = name.strip
-
.gsub(/\s+(Inc\.?|LLC|Ltd\.?|Corp\.?|Corporation|Company|Co\.?)$/i, "")
-
.strip
-
-
# Skip if too short or looks like garbage
-
return nil if normalized.length < 2
-
return nil if normalized =~ /^[^a-zA-Z]*$/
-
-
normalized
-
end
-
-
# Normalizes job title
-
#
-
# @param title [String] Raw job title
-
# @return [String, nil] Normalized title or nil if invalid
-
def normalize_job_title(title)
-
return nil if title.blank?
-
-
normalized = title.strip
-
-
# Skip if too short or looks like garbage
-
return nil if normalized.length < 2
-
return nil if normalized =~ /^[^a-zA-Z]*$/
-
-
normalized
-
end
-
-
# Saves resume date if extracted by AI
-
#
-
# @param skill_result [Hash] AI extraction result
-
# @return [void]
-
def save_resume_date(skill_result)
-
return unless skill_result[:resume_date].present?
-
-
user_resume.update!(
-
resume_updated_at: skill_result[:resume_date],
-
resume_date_confidence: skill_result[:resume_date_confidence],
-
resume_date_source: skill_result[:resume_date_source]
-
)
-
rescue StandardError => e
-
Rails.logger.warn("Failed to save resume date: #{e.message}")
-
end
-
-
# Aggregates skills for the user
-
def aggregate_user_skills
-
aggregation_service = SkillAggregationService.new(user_resume.user)
-
-
# Aggregate only skills from this resume
-
user_resume.skill_tags.each do |skill_tag|
-
aggregation_service.aggregate_skill(skill_tag)
-
end
-
end
-
-
# Persists resume-derived strengths and domains returned by the extractor.
-
#
-
# @param skill_result [Hash]
-
# @return [void]
-
def persist_strengths_and_domains(skill_result)
-
strengths = Array(skill_result[:strengths])
-
.map { |s| s.to_s.strip }
-
.reject(&:blank?)
-
-
# De-dupe near-duplicates within the same resume analysis (conservative threshold).
-
strengths = Labels::DedupeService.new(strengths, similarity_threshold: 0.9, overlap_threshold: 0.85).run
-
-
domains = Array(skill_result[:domains])
-
.map { |d| d.to_s.strip }
-
.reject(&:blank?)
-
.uniq
-
-
user_resume.update!(strengths: strengths, domains: domains)
-
rescue StandardError => e
-
Rails.logger.warn("Failed to persist strengths/domains: #{e.message}")
-
end
-
-
# Builds success result
-
#
-
# @param skills_count [Integer] Number of skills created
-
# @param skill_result [Hash] AI extraction result
-
# @return [Hash]
-
def success_result(skills_count, skill_result)
-
{
-
success: true,
-
skills_count: skills_count,
-
summary: skill_result[:summary],
-
strengths: skill_result[:strengths],
-
domains: skill_result[:domains],
-
provider: skill_result[:provider],
-
model: skill_result[:model]
-
}
-
end
-
-
# Builds failure result
-
#
-
# @param error [String] Error message
-
# @return [Hash]
-
def failure_result(error)
-
{
-
success: false,
-
error: error,
-
skills_count: 0
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Resumes
-
# Service for aggregating skills from multiple resumes into a unified user profile
-
#
-
# Uses weighted averaging based on:
-
# - Recency: Newer resumes have higher weight
-
# - Purpose: Role-specific resumes get slightly higher weight
-
# - User confirmation: User-adjusted levels take precedence
-
#
-
# @example
-
# service = Resumes::SkillAggregationService.new(user)
-
# service.aggregate_all # Recompute all skills
-
# service.aggregate_skill(ruby_skill) # Recompute single skill
-
#
-
class SkillAggregationService
-
# Weight multipliers
-
RECENCY_WEIGHTS = {
-
current_year: 1.0,
-
last_year: 0.8,
-
older: 0.6
-
}.freeze
-
-
PURPOSE_WEIGHTS = {
-
role_specific: 1.1,
-
company_specific: 1.0,
-
generic: 0.9
-
}.freeze
-
-
attr_reader :user
-
-
# Initialize the service
-
#
-
# @param user [User] The user to aggregate skills for
-
def initialize(user)
-
@user = user
-
end
-
-
# Aggregates all skills from all resumes
-
#
-
# @return [Array<UserSkill>] Updated user skills
-
def aggregate_all
-
# Get all unique skill_tag_ids from user's resumes
-
skill_tag_ids = ResumeSkill
-
.joins(:user_resume)
-
.where(user_resumes: { user_id: user.id })
-
.distinct
-
.pluck(:skill_tag_id)
-
-
skill_tag_ids.map do |skill_tag_id|
-
skill_tag = SkillTag.find(skill_tag_id)
-
aggregate_skill(skill_tag)
-
end.compact
-
end
-
-
# Aggregates a single skill across all resumes
-
#
-
# @param skill_tag [SkillTag] The skill to aggregate
-
# @return [UserSkill, nil] The updated user skill or nil if no data
-
def aggregate_skill(skill_tag)
-
resume_skills = fetch_resume_skills(skill_tag)
-
-
if resume_skills.empty?
-
# Remove user skill if no resume skills exist
-
user.user_skills.find_by(skill_tag: skill_tag)&.destroy
-
return nil
-
end
-
-
latest_by_resume_id = latest_demonstrated_dates_by_resume_id(skill_tag)
-
aggregated_data = compute_aggregated_data(resume_skills, latest_by_resume_id: latest_by_resume_id)
-
-
user_skill = user.user_skills.find_or_initialize_by(skill_tag: skill_tag)
-
user_skill.update!(
-
aggregated_level: aggregated_data[:level],
-
confidence_score: aggregated_data[:confidence],
-
category: aggregated_data[:category],
-
resume_count: resume_skills.size,
-
max_years_experience: aggregated_data[:max_years],
-
last_demonstrated_at: aggregated_data[:last_demonstrated_at]
-
)
-
-
user_skill
-
end
-
-
# Removes skills that no longer have any resume_skills
-
#
-
# @return [Integer] Number of skills removed
-
def cleanup_orphaned_skills
-
orphaned = user.user_skills.left_outer_joins(:skill_tag)
-
.joins("LEFT OUTER JOIN resume_skills ON resume_skills.skill_tag_id = user_skills.skill_tag_id
-
AND resume_skills.user_resume_id IN (SELECT id FROM user_resumes WHERE user_id = #{user.id})")
-
.where(resume_skills: { id: nil })
-
-
count = orphaned.count
-
orphaned.destroy_all
-
count
-
end
-
-
private
-
-
# Fetches all resume skills for a given skill tag
-
#
-
# @param skill_tag [SkillTag] The skill tag
-
# @return [Array<ResumeSkill>] Resume skills with resume data
-
def fetch_resume_skills(skill_tag)
-
ResumeSkill
-
.includes(:user_resume)
-
.joins(:user_resume)
-
.where(skill_tag: skill_tag, user_resumes: { user_id: user.id })
-
.to_a
-
end
-
-
# Computes aggregated data from resume skills
-
#
-
# @param resume_skills [Array<ResumeSkill>] Resume skills to aggregate
-
# @return [Hash] Aggregated data
-
def compute_aggregated_data(resume_skills, latest_by_resume_id:)
-
weighted_levels = []
-
weighted_confidences = []
-
categories = Hash.new(0)
-
max_years = nil
-
last_demonstrated = nil
-
-
resume_skills.each do |rs|
-
resume = rs.user_resume
-
weight = calculate_weight(resume)
-
-
# Use user_level if set, otherwise model_level
-
level = rs.effective_level
-
confidence = rs.confidence_score || 0.5
-
-
weighted_levels << { value: level, weight: weight }
-
weighted_confidences << { value: confidence, weight: weight }
-
categories[rs.category] += weight if rs.category.present?
-
-
# Track max years
-
max_years = [ max_years || 0, rs.years_of_experience || 0 ].max
-
-
# Track most recent demonstrated date for this skill (prefer work experience dates).
-
demonstrated_on =
-
latest_by_resume_id[resume.id] ||
-
resume.resume_updated_at ||
-
resume.created_at&.to_date
-
last_demonstrated = [ last_demonstrated, demonstrated_on ].compact.max
-
end
-
-
{
-
level: weighted_average(weighted_levels),
-
confidence: weighted_average(weighted_confidences),
-
category: categories.max_by { |_, v| v }&.first || "Other",
-
max_years: max_years.positive? ? max_years : nil,
-
last_demonstrated_at: last_demonstrated&.to_time&.in_time_zone
-
}
-
end
-
-
# Builds a map of user_resume_id => latest demonstrated Date for a given skill_tag,
-
# based on extracted work experience dates (best-effort).
-
#
-
# @param skill_tag [SkillTag]
-
# @return [Hash{Integer => Date}]
-
def latest_demonstrated_dates_by_resume_id(skill_tag)
-
rows = ResumeWorkExperienceSkill
-
.joins(resume_work_experience: :user_resume)
-
.where(skill_tag_id: skill_tag.id, user_resumes: { user_id: user.id })
-
.group("resume_work_experiences.user_resume_id")
-
.pluck(
-
Arel.sql("resume_work_experiences.user_resume_id"),
-
Arel.sql("MAX(CASE WHEN resume_work_experiences.current THEN CURRENT_DATE ELSE COALESCE(resume_work_experiences.end_date, resume_work_experiences.start_date) END)")
-
)
-
-
rows.to_h
-
rescue StandardError
-
{}
-
end
-
-
# Calculates weight for a resume based on recency and purpose
-
#
-
# @param resume [UserResume] The resume
-
# @return [Float] Weight multiplier
-
def calculate_weight(resume)
-
recency_weight = calculate_recency_weight(resume.created_at)
-
purpose_weight = PURPOSE_WEIGHTS[resume.purpose.to_sym] || 1.0
-
-
recency_weight * purpose_weight
-
end
-
-
# Calculates recency weight based on resume age
-
#
-
# @param created_at [DateTime] Resume creation date
-
# @return [Float] Recency weight
-
def calculate_recency_weight(created_at)
-
age_in_years = (Time.current - created_at) / 1.year
-
-
if age_in_years < 1
-
RECENCY_WEIGHTS[:current_year]
-
elsif age_in_years < 2
-
RECENCY_WEIGHTS[:last_year]
-
else
-
RECENCY_WEIGHTS[:older]
-
end
-
end
-
-
# Computes weighted average
-
#
-
# @param items [Array<Hash>] Array of {value:, weight:} hashes
-
# @return [Float] Weighted average
-
def weighted_average(items)
-
return 0.0 if items.empty?
-
-
total_weight = items.sum { |i| i[:weight] }
-
return 0.0 if total_weight.zero?
-
-
weighted_sum = items.sum { |i| i[:value] * i[:weight] }
-
(weighted_sum / total_weight).round(2)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Resumes
-
# Service for extracting text content from uploaded resume files
-
#
-
# Supports PDF, DOCX, DOC, and plain text files.
-
#
-
# @example
-
# service = Resumes::TextExtractorService.new(user_resume)
-
# result = service.extract
-
# if result[:success]
-
# puts result[:text]
-
# end
-
#
-
class TextExtractorService
-
# Maximum text length to prevent memory issues
-
MAX_TEXT_LENGTH = 500_000
-
-
attr_reader :user_resume
-
-
# Initialize the service
-
#
-
# @param user_resume [UserResume] The resume to extract text from
-
def initialize(user_resume)
-
@user_resume = user_resume
-
end
-
-
# Extracts text content from the resume file
-
#
-
# @return [Hash] Result with :success, :text, :error keys
-
def extract
-
return error_result("No file attached") unless user_resume.file.attached?
-
-
text = case user_resume.file_extension
-
when "pdf"
-
extract_from_pdf
-
when "docx"
-
extract_from_docx
-
when "doc"
-
extract_from_doc
-
when "txt"
-
extract_from_text
-
else
-
return error_result("Unsupported file type: #{user_resume.file_extension}")
-
end
-
-
return error_result("No text content extracted") if text.blank?
-
-
# Truncate if too long
-
text = text.truncate(MAX_TEXT_LENGTH) if text.length > MAX_TEXT_LENGTH
-
-
# Store the parsed text
-
user_resume.update!(parsed_text: text)
-
-
success_result(text)
-
rescue PDF::Reader::MalformedPDFError => e
-
error_result("Invalid or corrupted PDF file: #{e.message}")
-
rescue Docx::Errors::DocxError => e
-
error_result("Invalid or corrupted Word document: #{e.message}")
-
rescue StandardError => e
-
Rails.logger.error("Text extraction failed: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
-
error_result("Failed to extract text: #{e.message}")
-
end
-
-
private
-
-
# Extracts text from PDF files
-
#
-
# @return [String] Extracted text
-
def extract_from_pdf
-
download_and_process do |tempfile|
-
reader = PDF::Reader.new(tempfile.path)
-
pages_text = reader.pages.map do |page|
-
page.text
-
rescue StandardError => e
-
Rails.logger.warn("Failed to extract text from PDF page: #{e.message}")
-
""
-
end
-
pages_text.join("\n\n")
-
end
-
end
-
-
# Extracts text from DOCX files
-
#
-
# @return [String] Extracted text
-
def extract_from_docx
-
download_and_process do |tempfile|
-
doc = Docx::Document.open(tempfile.path)
-
paragraphs = doc.paragraphs.map(&:text)
-
paragraphs.join("\n\n")
-
end
-
end
-
-
# Extracts text from DOC files (legacy Word format)
-
# Falls back to antiword or catdoc if available, otherwise tries basic extraction
-
#
-
# @return [String] Extracted text
-
def extract_from_doc
-
download_and_process do |tempfile|
-
# Try antiword first (common on Linux)
-
if system("which antiword > /dev/null 2>&1")
-
`antiword #{Shellwords.escape(tempfile.path)} 2>/dev/null`
-
# Try catdoc as fallback
-
elsif system("which catdoc > /dev/null 2>&1")
-
`catdoc #{Shellwords.escape(tempfile.path)} 2>/dev/null`
-
else
-
# Basic fallback - try to read as text with encoding handling
-
content = File.read(tempfile.path, encoding: "ISO-8859-1")
-
# Extract readable text portions
-
content.encode("UTF-8", invalid: :replace, undef: :replace)
-
.gsub(/[^\x20-\x7E\n\r\t]/, " ")
-
.gsub(/\s+/, " ")
-
.strip
-
end
-
end
-
end
-
-
# Extracts text from plain text files
-
#
-
# @return [String] Extracted text
-
def extract_from_text
-
download_and_process do |tempfile|
-
File.read(tempfile.path, encoding: "UTF-8")
-
rescue Encoding::InvalidByteSequenceError
-
File.read(tempfile.path, encoding: "ISO-8859-1").encode("UTF-8")
-
end
-
end
-
-
# Downloads the file to a tempfile and yields it for processing
-
#
-
# @yield [Tempfile] The downloaded file
-
# @return [String] Result from the block
-
def download_and_process
-
extension = ".#{user_resume.file_extension}"
-
tempfile = Tempfile.new([ "resume", extension ])
-
tempfile.binmode
-
-
begin
-
user_resume.file.download { |chunk| tempfile.write(chunk) }
-
tempfile.rewind
-
yield tempfile
-
ensure
-
tempfile.close
-
tempfile.unlink
-
end
-
end
-
-
# Builds a success result hash
-
#
-
# @param text [String] Extracted text
-
# @return [Hash]
-
def success_result(text)
-
{
-
success: true,
-
text: clean_text(text),
-
char_count: text.length,
-
word_count: text.split(/\s+/).count
-
}
-
end
-
-
# Builds an error result hash
-
#
-
# @param message [String] Error message
-
# @return [Hash]
-
def error_result(message)
-
{
-
success: false,
-
error: message
-
}
-
end
-
-
# Cleans extracted text for better AI processing
-
#
-
# @param text [String] Raw text
-
# @return [String] Cleaned text
-
def clean_text(text)
-
text
-
.gsub(/\r\n/, "\n") # Normalize line endings
-
.gsub(/\r/, "\n")
-
.gsub(/\n{3,}/, "\n\n") # Collapse multiple newlines
-
.gsub(/[ \t]+/, " ") # Collapse multiple spaces
-
.gsub(/^\s+$/, "") # Remove whitespace-only lines
-
.strip
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Resumes
-
# Aggregates work history across all analyzed resumes into a merged user-level profile.
-
class WorkHistoryAggregationService
-
# @param user [User]
-
def initialize(user)
-
@user = user
-
end
-
-
# @return [void]
-
def run
-
ActiveRecord::Base.transaction do
-
rebuild_user_work_experiences!
-
end
-
end
-
-
private
-
-
attr_reader :user
-
-
def rebuild_user_work_experiences!
-
# Clear and rebuild for correctness (can be optimized later).
-
UserWorkExperience.where(user: user).destroy_all
-
-
resume_experiences = ResumeWorkExperience
-
.joins(:user_resume)
-
.includes(:company, :job_role, :resume_work_experience_skills, :skill_tags)
-
.where(user_resumes: { user_id: user.id, analysis_status: UserResume.analysis_statuses[:completed] })
-
.to_a
-
-
groups = resume_experiences.group_by { |rwe| merge_key_for(rwe) }
-
-
groups.each do |key, items|
-
next if key.blank?
-
-
merged = build_merged_experience(items)
-
uwe = UserWorkExperience.create!(merged.merge(user: user))
-
-
items.each do |rwe|
-
UserWorkExperienceSource.create!(user_work_experience: uwe, resume_work_experience: rwe)
-
end
-
-
upsert_experience_skills!(uwe, items)
-
end
-
end
-
-
def merge_key_for(rwe)
-
company = rwe.display_company_name.to_s.strip.downcase
-
role = rwe.display_role_title.to_s.strip.downcase
-
[ company.presence, role.presence ].compact.join("|")
-
end
-
-
def build_merged_experience(items)
-
first = items.first
-
-
company_name = items.map(&:display_company_name).map { |s| s.to_s.strip }.reject(&:blank?).first
-
role_title = items.map(&:display_role_title).map { |s| s.to_s.strip }.reject(&:blank?).first
-
-
start_date = items.map(&:start_date).compact.min
-
end_date = items.map(&:end_date).compact.max
-
current = items.any?(&:current)
-
-
responsibilities = items.flat_map { |i| Array(i.responsibilities) }.map { |s| s.to_s.strip }.reject(&:blank?).uniq.first(50)
-
highlights = items.flat_map { |i| Array(i.highlights) }.map { |s| s.to_s.strip }.reject(&:blank?).uniq.first(50)
-
-
{
-
company: first.company,
-
job_role: first.job_role,
-
company_name: company_name,
-
role_title: role_title,
-
start_date: start_date,
-
end_date: end_date,
-
current: current,
-
responsibilities: responsibilities,
-
highlights: highlights,
-
source_count: items.size,
-
merge_keys: { merge_key: merge_key_for(first) }
-
}
-
end
-
-
def upsert_experience_skills!(user_work_experience, resume_items)
-
# Build counts across source experiences.
-
skills = Hash.new { |h, k| h[k] = { count: 0, last_used_on: nil } }
-
-
resume_items.each do |rwe|
-
last_used_on = rwe.end_date || rwe.start_date
-
last_used_on = Date.current if rwe.current
-
-
rwe.resume_work_experience_skills.each do |row|
-
entry = skills[row.skill_tag_id]
-
entry[:count] += 1
-
entry[:last_used_on] = [ entry[:last_used_on], last_used_on ].compact.max
-
end
-
end
-
-
skills.each do |skill_tag_id, data|
-
UserWorkExperienceSkill.create!(
-
user_work_experience: user_work_experience,
-
skill_tag_id: skill_tag_id,
-
source_count: data[:count],
-
last_used_on: data[:last_used_on]
-
)
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
# Service for AI-powered job listing extraction
-
#
-
# Uses configured LLM providers to extract structured data from HTML,
-
# with automatic fallback to alternative providers on failure.
-
# Supports idempotent retries by accepting pre-fetched HTML content.
-
#
-
# @example
-
# extractor = Scraping::AiJobExtractorService.new(job_listing, scraping_attempt)
-
# result = extractor.extract(html_content: cached_html)
-
# if result[:confidence] >= 0.7
-
# # Use extracted data
-
# end
-
class AiJobExtractorService < ApplicationService
-
include Concerns::Loggable
-
-
attr_reader :job_listing, :scraping_attempt, :url
-
-
# Initialize the extractor
-
#
-
# @param job_listing [JobListing] The job listing
-
# @param scraping_attempt [ScrapingAttempt, nil] Optional scraping attempt
-
def initialize(job_listing, scraping_attempt: nil)
-
@job_listing = job_listing
-
@scraping_attempt = scraping_attempt
-
@url = job_listing.url
-
end
-
-
# Extracts job data using AI providers with fallback
-
#
-
# @param html_content [String, nil] Pre-fetched HTML content (for idempotent retries)
-
# @param cleaned_html [String, nil] Pre-cleaned HTML content
-
# @return [Hash] Extracted job data with confidence score
-
def extract(html_content: nil, cleaned_html: nil)
-
log_event("ai_extraction_started")
-
-
html_for_extraction = get_html_content(html_content, cleaned_html)
-
return extraction_error("No HTML content available") unless html_for_extraction.present?
-
-
prompt = build_extraction_prompt(html_for_extraction)
-
extract_with_providers(prompt, html_for_extraction.bytesize)
-
end
-
-
private
-
-
def get_html_content(html_content, cleaned_html)
-
if cleaned_html.present?
-
cleaned_html
-
elsif html_content.present?
-
Scraping::NokogiriHtmlCleanerService.new.clean(html_content)
-
else
-
fetch_html_content
-
end
-
end
-
-
def fetch_html_content
-
fetch_result = Scraping::HtmlFetcherService.new(@job_listing, scraping_attempt: @scraping_attempt).call
-
unless fetch_result[:success]
-
log_event("ai_extraction_failed", { error: fetch_result[:error] || "Failed to fetch HTML" })
-
return nil
-
end
-
fetch_result[:cleaned_html]
-
end
-
-
def extract_with_providers(prompt, html_size)
-
prompt_template = Ai::JobExtractionPrompt.active_prompt
-
system_message = prompt_template&.system_prompt.presence || Ai::JobExtractionPrompt.default_system_prompt
-
-
runner = Ai::ProviderRunnerService.new(
-
provider_chain: provider_chain,
-
prompt: prompt,
-
content_size: html_size,
-
system_message: system_message,
-
provider_for: method(:get_provider_instance),
-
logger_builder: lambda { |provider_name, provider|
-
Ai::ApiLoggerService.new(
-
operation_type: :job_extraction,
-
loggable: @job_listing,
-
provider: provider_name,
-
model: provider.respond_to?(:model_name) ? provider.model_name : "unknown",
-
llm_prompt: prompt_template
-
)
-
},
-
on_rate_limit: lambda { |response, provider_name, _logger|
-
handle_rate_limit(provider_name, response)
-
},
-
on_error: lambda { |response, provider_name, _logger|
-
log_event("ai_extraction_failed", { provider: provider_name, error: response[:error] })
-
},
-
operation: :job_extraction,
-
loggable: @job_listing,
-
user: job_listing_user,
-
error_context: {
-
severity: "warning",
-
job_listing_id: @job_listing&.id,
-
url: @url
-
}
-
)
-
-
result = runner.run do |response|
-
parsed = parse_response(response[:content])
-
log_data = (parsed || {}).merge(
-
confidence: parsed&.dig(:confidence),
-
model: response[:model]
-
)
-
[ parsed, log_data, true ]
-
end
-
-
return extraction_error("All providers failed or returned low confidence") unless result[:success]
-
-
confidence = result[:parsed][:confidence] || 0.0
-
if confidence >= 0.7
-
log_event("ai_extraction_succeeded", { provider: result[:provider], confidence: confidence })
-
else
-
log_event("ai_extraction_low_confidence", { provider: result[:provider], confidence: confidence })
-
end
-
-
build_extraction_result(result[:parsed], result[:result].merge(model: result[:model]), result[:provider]).merge(
-
prompt_used: prompt
-
)
-
end
-
-
def job_listing_user
-
@job_listing.interview_applications.order(created_at: :desc).first&.user
-
end
-
-
def handle_rate_limit(provider_name, result)
-
log_event("ai_extraction_rate_limited", {
-
provider: provider_name,
-
retry_after: result[:retry_after]
-
})
-
-
if result[:retry_after] && result[:retry_after] > 0
-
wait_time = [ result[:retry_after], 60 ].min
-
sleep(wait_time)
-
end
-
end
-
-
def build_extraction_result(parsed, result, provider_name)
-
parsed.merge(
-
provider: provider_name,
-
model: result[:model],
-
input_tokens: result[:input_tokens],
-
output_tokens: result[:output_tokens],
-
raw_response: result[:content] || result[:raw_response]
-
)
-
end
-
-
def extraction_error(message)
-
log_event("ai_extraction_failed", { error: message })
-
{ error: message, confidence: 0.0 }
-
end
-
-
# Prompt building
-
-
def build_extraction_prompt(html_content)
-
prompt_template = Ai::JobExtractionPrompt.active_prompt
-
-
if prompt_template && prompt_supports_company_sections?(prompt_template.prompt_template)
-
prompt_template.build_prompt(url: @url, html_content: html_content)
-
else
-
Ai::JobExtractionPrompt.default_prompt_template
-
.gsub("{{url}}", @url)
-
.gsub("{{html_content}}", html_content)
-
end
-
end
-
-
def prompt_supports_company_sections?(template)
-
return false unless template.is_a?(String)
-
-
template.include?("about_company") && template.include?("company_culture")
-
end
-
-
# Response parsing
-
-
def parse_response(response_text)
-
return { error: "No response", confidence: 0.0 } unless response_text.present?
-
-
json_match = response_text.match(/\{.*\}/m)
-
return { error: "No JSON found in response", confidence: 0.0 } unless json_match
-
-
data = JSON.parse(json_match[0])
-
normalize_parsed_data(data)
-
rescue JSON::ParserError => e
-
Rails.logger.error("Failed to parse LLM response: #{e.message}")
-
{ error: "Invalid JSON response", confidence: 0.0 }
-
end
-
-
def normalize_parsed_data(data)
-
# Build custom_sections with markdown and additional extracted fields
-
custom_sections = data["custom_sections"] || {}
-
custom_sections["description_markdown"] = data["description_markdown"] if data["description_markdown"].present?
-
custom_sections["compensation_text"] = data["compensation_text"] if data["compensation_text"].present?
-
custom_sections["interview_process"] = data["interview_process"] if data["interview_process"].present?
-
-
{
-
title: data["title"],
-
company: data["company"] || data["company_name"],
-
job_role: data["job_role"] || data["job_role_title"],
-
job_role_department: data["job_role_department"],
-
job_board: data["job_board"],
-
description: data["description"],
-
description_markdown: data["description_markdown"],
-
about_company: data["about_company"],
-
company_culture: data["company_culture"],
-
requirements: data["requirements"],
-
responsibilities: data["responsibilities"],
-
location: data["location"],
-
remote_type: data["remote_type"],
-
salary_min: data["salary_min"],
-
salary_max: data["salary_max"],
-
salary_currency: data["salary_currency"] || "USD",
-
compensation_text: data["compensation_text"],
-
equity_info: data["equity_info"],
-
benefits: data["benefits"],
-
perks: data["perks"],
-
interview_process: data["interview_process"],
-
custom_sections: custom_sections,
-
confidence: data["confidence_score"] || 0.5,
-
notes: data["notes"]
-
}
-
end
-
-
# Provider management
-
-
def provider_chain
-
LlmProviders::ProviderConfigHelper.all_providers
-
end
-
-
def get_provider_instance(provider_name)
-
provider = case provider_name.to_s.downcase
-
when "openai" then LlmProviders::OpenaiProvider.new
-
when "anthropic" then LlmProviders::AnthropicProvider.new
-
when "ollama" then LlmProviders::OllamaProvider.new
-
else raise ArgumentError, "Unknown provider: #{provider_name}"
-
end
-
-
provider.scraping_attempt = @scraping_attempt
-
provider.job_listing = @job_listing
-
provider
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
# LLM-based post-processor for job content.
-
#
-
# Used after a high-confidence source (e.g., Greenhouse boards API) fetch to:
-
# - Extract missing structured fields (compensation, interview process, etc.)
-
# - Produce a clean Markdown version for display
-
#
-
# This is deliberately "best effort" and should not fail the scrape.
-
class AiJobPostProcessorService < ApplicationService
-
attr_reader :job_listing, :scraping_attempt
-
-
# @param job_listing [JobListing]
-
# @param scraping_attempt [ScrapingAttempt, nil]
-
# @param providers [Array<String>, nil] Optional provider chain override
-
def initialize(job_listing, scraping_attempt: nil, providers: nil)
-
@job_listing = job_listing
-
@scraping_attempt = scraping_attempt
-
@providers = providers
-
end
-
-
# @param content_html [String]
-
# @param url [String]
-
# @return [Hash] normalized result hash (best effort)
-
def run(content_html:, url:)
-
return { error: "No content", confidence: 0.0 } if content_html.blank?
-
-
prompt_template = Ai::JobPostprocessPrompt.active_prompt
-
prompt = if prompt_template
-
prompt_template.build_prompt(url: url, html_content: content_html)
-
else
-
Ai::JobPostprocessPrompt.default_prompt_template
-
.gsub("{{url}}", url)
-
.gsub("{{html_content}}", content_html)
-
end
-
-
system_message = prompt_template&.system_prompt.presence || Ai::JobPostprocessPrompt.default_system_prompt
-
runner = Ai::ProviderRunnerService.new(
-
provider_chain: provider_chain,
-
prompt: prompt,
-
content_size: content_html.bytesize,
-
system_message: system_message,
-
provider_for: method(:get_provider_instance),
-
logger_builder: lambda { |name, provider_instance|
-
Ai::ApiLoggerService.new(
-
operation_type: :job_postprocess,
-
loggable: job_listing,
-
provider: name,
-
model: provider_instance.respond_to?(:model_name) ? provider_instance.model_name : "unknown",
-
llm_prompt: prompt_template
-
)
-
},
-
operation: :job_postprocess,
-
loggable: job_listing,
-
user: job_listing&.user,
-
error_context: {
-
severity: "warning",
-
job_listing_id: job_listing&.id,
-
scraping_attempt_id: scraping_attempt&.id,
-
url: url
-
}
-
)
-
-
result = runner.run do |response|
-
parsed = parse_json_result(response[:content])
-
confidence = parsed[:confidence].to_f
-
log_data = parsed.merge(
-
content: response[:content],
-
confidence: confidence,
-
error: response[:error],
-
error_type: response[:error_type]
-
)
-
accept = confidence > 0.0
-
[ parsed, log_data, accept ]
-
end
-
-
return result[:parsed] if result[:success]
-
-
{ error: "No provider available", confidence: 0.0 }
-
rescue => e
-
notify_ai_error(
-
e,
-
operation: "job_postprocess",
-
loggable: job_listing,
-
severity: "warning",
-
job_listing_id: job_listing.id,
-
scraping_attempt_id: scraping_attempt&.id,
-
url: url
-
)
-
{ error: e.message, confidence: 0.0 }
-
end
-
-
private
-
-
def parse_json_result(text)
-
return { error: "No response", confidence: 0.0 } if text.blank?
-
-
data = Ai::ResponseParserService.new(text).parse
-
return { error: "No JSON found", confidence: 0.0 } unless data
-
-
normalize_parsed_data(data)
-
rescue JSON::ParserError => e
-
{ error: "Invalid JSON: #{e.message}", confidence: 0.0 }
-
end
-
-
def normalize_parsed_data(data)
-
{
-
job_markdown: data["job_markdown"].to_s,
-
compensation_text: data["compensation_text"],
-
salary_min: data["salary_min"],
-
salary_max: data["salary_max"],
-
salary_currency: data["salary_currency"],
-
interview_process: data["interview_process"],
-
responsibilities_bullets: Array(data["responsibilities_bullets"]).map(&:to_s),
-
requirements_bullets: Array(data["requirements_bullets"]).map(&:to_s),
-
benefits_bullets: Array(data["benefits_bullets"]).map(&:to_s),
-
perks_bullets: Array(data["perks_bullets"]).map(&:to_s),
-
confidence: data["confidence_score"].to_f
-
}
-
end
-
-
def provider_chain
-
@providers.presence || LlmProviders::ProviderConfigHelper.all_providers
-
end
-
-
def get_provider_instance(provider_name)
-
provider = case provider_name.to_s.downcase
-
when "openai" then LlmProviders::OpenaiProvider.new
-
when "anthropic" then LlmProviders::AnthropicProvider.new
-
when "ollama" then LlmProviders::OllamaProvider.new
-
else raise ArgumentError, "Unknown provider: #{provider_name}"
-
end
-
-
provider.scraping_attempt = scraping_attempt
-
provider.job_listing = job_listing
-
provider
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
# Service for tracking and limiting Anthropic API token usage
-
#
-
# Implements a rolling window rate limiter to ensure we don't exceed
-
# Anthropic's 30,000 input tokens per minute limit.
-
#
-
# @example
-
# limiter = Scraping::AnthropicRateLimiterService.new
-
# if limiter.can_send_tokens?(estimated_tokens)
-
# # Make request
-
# limiter.record_tokens_used(actual_tokens)
-
# else
-
# wait_time = limiter.wait_time_for_tokens(estimated_tokens)
-
# sleep(wait_time)
-
# end
-
class AnthropicRateLimiterService
-
TOKEN_LIMIT_PER_MINUTE = 30_000
-
WINDOW_SECONDS = 60
-
CACHE_KEY = "anthropic_token_usage"
-
CACHE_EXPIRATION = 2.minutes # Longer than window to handle edge cases
-
-
# Checks if a request with estimated tokens can be sent
-
#
-
# @param [Integer] estimated_tokens Estimated token count for the request
-
# @return [Boolean] True if request can be sent
-
def can_send_tokens?(estimated_tokens)
-
current_usage = total_usage_in_window
-
(current_usage + estimated_tokens) <= TOKEN_LIMIT_PER_MINUTE
-
end
-
-
# Calculates wait time needed before sending tokens
-
#
-
# @param [Integer] estimated_tokens Estimated token count
-
# @return [Float] Seconds to wait (0 if can send immediately)
-
def wait_time_for_tokens(estimated_tokens)
-
return 0.0 if can_send_tokens?(estimated_tokens)
-
-
current_usage = total_usage_in_window
-
tokens_needed = estimated_tokens
-
tokens_available = TOKEN_LIMIT_PER_MINUTE - current_usage
-
-
if tokens_available < tokens_needed
-
# Need to wait for window to roll over
-
oldest_timestamp = oldest_request_time
-
return 0.0 if oldest_timestamp.nil?
-
-
elapsed = Time.current - oldest_timestamp
-
remaining = WINDOW_SECONDS - elapsed
-
[ remaining.ceil, 0 ].max.to_f
-
else
-
0.0
-
end
-
end
-
-
# Records token usage for a request
-
#
-
# @param [Integer] tokens Actual tokens used
-
# @return [void]
-
def record_tokens_used(tokens)
-
return if tokens.nil? || tokens <= 0
-
-
usage = current_usage_array
-
usage << { timestamp: Time.current, tokens: tokens }
-
-
# Clean up old entries (older than 1 minute)
-
cleanup_old_entries(usage)
-
-
# Store back to cache
-
Rails.cache.write(CACHE_KEY, usage, expires_in: CACHE_EXPIRATION)
-
end
-
-
# Gets current total token usage in the rolling window
-
#
-
# @return [Integer] Total tokens used in last 60 seconds
-
def total_usage_in_window
-
usage = current_usage_array
-
cleanup_old_entries(usage)
-
-
usage.sum { |entry| entry[:tokens] }
-
end
-
-
private
-
-
# Gets the current usage array from cache
-
#
-
# @return [Array<Hash>] Array of {timestamp, tokens} entries
-
def current_usage_array
-
Rails.cache.read(CACHE_KEY) || []
-
end
-
-
# Removes entries older than the window
-
#
-
# @param [Array<Hash>] usage The usage array (modified in place)
-
# @return [void]
-
def cleanup_old_entries(usage)
-
cutoff = Time.current - WINDOW_SECONDS.seconds
-
usage.reject! { |entry| entry[:timestamp] < cutoff }
-
end
-
-
# Gets the timestamp of the oldest request in the window
-
#
-
# @return [Time, nil] Oldest timestamp or nil
-
def oldest_request_time
-
usage = current_usage_array
-
return nil if usage.empty?
-
-
usage.map { |e| e[:timestamp] }.min
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module Concerns
-
# Concern for consistent structured logging across scraping services
-
#
-
# Provides standardized logging methods that include service context
-
# and job listing information for better traceability.
-
#
-
# @example
-
# class MyService
-
# include Scraping::Concerns::Loggable
-
#
-
# def initialize(job_listing, scraping_attempt: nil)
-
# @job_listing = job_listing
-
# @scraping_attempt = scraping_attempt
-
# @url = job_listing.url
-
# end
-
#
-
# def call
-
# log_event("operation_started")
-
# # ... do work
-
# log_event("operation_completed", { result: "success" })
-
# end
-
# end
-
module Loggable
-
# Logs a structured event
-
#
-
# @param [String] event_name The event name
-
# @param [Hash] data Additional event data
-
def log_event(event_name, data = {})
-
base_data = {
-
event: event_name,
-
service: self.class.name,
-
job_listing_id: @job_listing&.id,
-
scraping_attempt_id: @scraping_attempt&.id,
-
url: @url || @job_listing&.url
-
}
-
Rails.logger.info(base_data.merge(data).to_json)
-
end
-
-
# Logs an error with optional exception details
-
#
-
# @param [String] message The error message
-
# @param [Exception, nil] exception Optional exception object
-
def log_error(message, exception = nil)
-
error_data = {
-
error: message,
-
service: self.class.name,
-
job_listing_id: @job_listing&.id,
-
scraping_attempt_id: @scraping_attempt&.id,
-
url: @url || @job_listing&.url
-
}
-
-
if exception
-
error_data.merge!(
-
exception: exception.class.name,
-
message: exception.message,
-
backtrace: exception.backtrace&.first(5)
-
)
-
end
-
-
Rails.logger.error(error_data.to_json)
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Scraping
-
# Service for recording scraping pipeline events
-
#
-
# Wraps each step of the extraction process to capture timing, payloads,
-
# and status for complete observability.
-
#
-
# @example
-
# recorder = Scraping::EventRecorderService.new(attempt)
-
# result = recorder.record(:html_fetch, step: 1, input: { url: url }) do |event|
-
# html = fetch_html(url)
-
# event.set_output(html_size: html.bytesize, http_status: 200)
-
# html
-
# end
-
class EventRecorderService
-
attr_reader :scraping_attempt, :job_listing, :current_step
-
-
# Initialize the recorder with a scraping attempt
-
#
-
# @param [ScrapingAttempt] scraping_attempt The scraping attempt to record events for
-
# @param [JobListing, nil] job_listing Optional job listing reference
-
def initialize(scraping_attempt, job_listing: nil)
-
@scraping_attempt = scraping_attempt
-
@job_listing = job_listing || scraping_attempt.job_listing
-
@current_step = 0
-
end
-
-
# Records an event for a pipeline step
-
#
-
# Wraps the block with timing and captures input/output payloads.
-
# If the block raises an exception, records a failure and re-raises.
-
#
-
# @param [Symbol] event_type The type of event (from ScrapingEvent::EVENT_TYPES)
-
# @param [Integer, nil] step Override step order (auto-increments if nil)
-
# @param [Hash] input Input payload to record
-
# @param [Hash] metadata Additional metadata
-
# @yield [EventContext] Block that performs the step
-
# @return [Object] Return value from the block
-
def record(event_type, step: nil, input: {}, metadata: {})
-
@current_step = step || (@current_step + 1)
-
-
event = create_event(event_type, input, metadata)
-
context = EventContext.new(event)
-
-
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
-
-
begin
-
result = yield(context) if block_given?
-
-
# Calculate duration
-
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
-
duration_ms = ((end_time - start_time) * 1000).round
-
-
# Update event with success
-
event.update!(
-
status: :success,
-
completed_at: Time.current,
-
duration_ms: duration_ms,
-
output_payload: context.output_data.merge(output_payload_from_result(result))
-
)
-
-
result
-
rescue => e
-
# Calculate duration even for failures
-
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
-
duration_ms = ((end_time - start_time) * 1000).round
-
-
# Update event with failure
-
event.update!(
-
status: :failed,
-
completed_at: Time.current,
-
duration_ms: duration_ms,
-
error_type: e.class.name,
-
error_message: e.message,
-
output_payload: context.output_data
-
)
-
-
raise
-
end
-
end
-
-
# Records a simple event without wrapping a block
-
#
-
# Useful for recording events that don't have a distinct start/end.
-
#
-
# @param [Symbol] event_type The type of event
-
# @param [Symbol] status The event status (:success, :failed, :skipped)
-
# @param [Hash] input Input payload
-
# @param [Hash] output Output payload
-
# @param [Hash] metadata Additional metadata
-
# @param [String, nil] error_message Error message if failed
-
# @param [String, nil] error_type Error type if failed
-
# @return [ScrapingEvent] The created event
-
def record_simple(event_type, status:, input: {}, output: {}, metadata: {}, error_message: nil, error_type: nil)
-
@current_step += 1
-
-
ScrapingEvent.create!(
-
scraping_attempt: @scraping_attempt,
-
job_listing: @job_listing,
-
event_type: event_type,
-
step_order: @current_step,
-
status: status,
-
started_at: Time.current,
-
completed_at: Time.current,
-
duration_ms: 0,
-
input_payload: truncate_payload(input),
-
output_payload: truncate_payload(output),
-
metadata: metadata,
-
error_type: error_type,
-
error_message: error_message
-
)
-
end
-
-
# Records a skipped step
-
#
-
# @param [Symbol] event_type The type of event
-
# @param [String] reason Why the step was skipped
-
# @param [Hash] metadata Additional metadata
-
# @return [ScrapingEvent] The created event
-
def record_skipped(event_type, reason:, metadata: {})
-
@current_step += 1
-
-
ScrapingEvent.create!(
-
scraping_attempt: @scraping_attempt,
-
job_listing: @job_listing,
-
event_type: event_type,
-
step_order: @current_step,
-
status: :skipped,
-
started_at: Time.current,
-
completed_at: Time.current,
-
duration_ms: 0,
-
input_payload: {},
-
output_payload: { skipped_reason: reason },
-
metadata: metadata
-
)
-
end
-
-
# Records a completion event
-
#
-
# @param [Hash] summary Summary of the extraction
-
# @return [ScrapingEvent] The created event
-
def record_completion(summary: {})
-
record_simple(
-
:completion,
-
status: :success,
-
output: summary,
-
metadata: { total_steps: @current_step }
-
)
-
end
-
-
# Records a failure event
-
#
-
# @param [String] message Failure message
-
# @param [String, nil] error_type Type of error
-
# @param [Hash] details Additional failure details
-
# @return [ScrapingEvent] The created event
-
def record_failure(message:, error_type: nil, details: {})
-
record_simple(
-
:failure,
-
status: :failed,
-
output: details,
-
error_message: message,
-
error_type: error_type,
-
metadata: { total_steps: @current_step }
-
)
-
end
-
-
private
-
-
# Creates a new event record
-
#
-
# @param [Symbol] event_type The type of event
-
# @param [Hash] input Input payload
-
# @param [Hash] metadata Additional metadata
-
# @return [ScrapingEvent] The created event
-
def create_event(event_type, input, metadata)
-
ScrapingEvent.create!(
-
scraping_attempt: @scraping_attempt,
-
job_listing: @job_listing,
-
event_type: event_type,
-
step_order: @current_step,
-
status: :started,
-
started_at: Time.current,
-
input_payload: truncate_payload(input),
-
output_payload: {},
-
metadata: metadata
-
)
-
end
-
-
# Extracts output payload from a result
-
#
-
# @param [Object] result The result to extract from
-
# @return [Hash] Extracted payload
-
def output_payload_from_result(result)
-
return {} unless result.is_a?(Hash)
-
-
# Only include relevant keys, not the entire result
-
safe_keys = %i[
-
success error confidence html_size http_status
-
cleaned_html_size content_length text_length
-
board_type company_slug job_id api_supported
-
fetch_mode rendered extractor_kind run_context
-
missing_fields selectors_tried
-
extracted_fields provider model tokens_used
-
title company location
-
]
-
-
result.slice(*safe_keys).transform_values { |v| truncate_value(v) }
-
end
-
-
# Truncates a payload to prevent excessive storage
-
#
-
# @param [Hash] payload The payload to truncate
-
# @return [Hash] Truncated payload
-
def truncate_payload(payload)
-
return {} unless payload.is_a?(Hash)
-
-
payload.transform_values { |v| truncate_value(v) }
-
end
-
-
# Truncates a single value
-
#
-
# @param [Object] value The value to truncate
-
# @return [Object] Truncated value
-
def truncate_value(value)
-
case value
-
when String
-
value.length > 10_000 ? "#{value[0...10_000]}... [TRUNCATED]" : value
-
when Hash
-
value.transform_values { |v| truncate_value(v) }
-
when Array
-
value.first(100).map { |v| truncate_value(v) }
-
else
-
value
-
end
-
end
-
-
# Context object passed to the block for setting output
-
class EventContext
-
attr_reader :output_data
-
-
def initialize(event)
-
@event = event
-
@output_data = {}
-
end
-
-
# Sets output data for the event
-
#
-
# @param [Hash] data The output data
-
def set_output(data)
-
@output_data.merge!(data)
-
end
-
-
# Adds to output data
-
#
-
# @param [Symbol, String] key The key
-
# @param [Object] value The value
-
def add_output(key, value)
-
@output_data[key] = value
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
# Classifies scraping failures into "retryable" vs "terminal/logical".
-
#
-
# We only want DLQ + retries for issues that can plausibly succeed on retry:
-
# - transient network issues (timeouts, 5xx, connection resets)
-
# - provider outages / Selenium hiccups
-
# - unexpected exceptions (bugs)
-
#
-
# We do NOT want DLQ for "logical" failures like:
-
# - low confidence extraction
-
# - thin HTML / rendered shell pages (not enough content)
-
# - 404/410 (resource missing)
-
#
-
# @example
-
# classifier = Scraping::FailureClassifierService.new(attempt)
-
# classifier.retryable? #=> true/false
-
class FailureClassifierService
-
# @param [ScrapingAttempt] scraping_attempt
-
def initialize(scraping_attempt)
-
@attempt = scraping_attempt
-
end
-
-
# @return [Boolean]
-
def retryable?
-
return false if logical_low_confidence?
-
return false if logical_thin_html?
-
return false if logical_http_not_found?
-
-
# If we can’t confidently classify it as logical, default to retryable.
-
true
-
rescue
-
true
-
end
-
-
private
-
-
def logical_low_confidence?
-
return false unless @attempt.failed_step.to_s == "ai_extraction"
-
-
@attempt.error_message.to_s.match?(/\Alow confidence:/i) ||
-
@attempt.error_message.to_s.match?(/extraction failed: low confidence/i)
-
end
-
-
def logical_thin_html?
-
event = @attempt.scraping_events.where(event_type: :rendered_html_fetch).order(created_at: :desc).first
-
output = event&.output_payload
-
return false unless output.is_a?(Hash)
-
-
output["rendered_shell"] == true || output["cleaned_text_length"].to_i < 300
-
end
-
-
def logical_http_not_found?
-
return false unless @attempt.failed_step.to_s == "html_fetch"
-
-
msg = @attempt.error_message.to_s
-
msg.match?(/\AHTTP\s+404:/i) || msg.match?(/\AHTTP\s+410:/i) || msg.match?(/\AHTTP\s+403:/i)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module HtmlCleaners
-
# HTML cleaner optimized for Ashby job board pages (jobs.ashbyhq.com)
-
#
-
# Ashby uses React with dynamically generated class names like `_title_ud4nd_34`.
-
# Key structural elements have stable classes like `ashby-job-posting-*`.
-
class AshbyCleaner < BaseCleaner
-
def initialize
-
super(board_type: :ashbyhq)
-
end
-
-
protected
-
-
# Ashby-specific elements to remove
-
def elements_to_remove
-
super + [
-
# Ashby navigation and back button
-
".ashby-job-board-back-to-all-jobs-button",
-
"[class*='_navRoot_']",
-
"[class*='_navContainer_']",
-
# Tab navigation (Overview/Application tabs)
-
"[role='tablist']",
-
"[class*='_tabs_']",
-
# Application form (we just want the job description)
-
"[class*='_applicationForm_']",
-
"form",
-
# Social sharing
-
"[class*='share']", "[class*='social']"
-
]
-
end
-
-
# Ashby-specific content selectors in priority order
-
def main_content_selectors
-
[
-
# Primary content area (job description)
-
".ashby-job-posting-right-pane",
-
# Container with all job details
-
"[class*='_details_']",
-
"[class*='_content_']",
-
# Left pane has metadata (location, type, etc.)
-
".ashby-job-posting-left-pane",
-
# React root as fallback
-
"#root",
-
# Generic fallbacks
-
"body"
-
]
-
end
-
-
# Preserve job-related content even if it matches removal patterns
-
def elements_to_preserve
-
[
-
".ashby-job-posting-heading",
-
".ashby-job-posting-right-pane",
-
".ashby-job-posting-left-pane",
-
"[class*='_section_']"
-
]
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
require "nokogiri"
-
-
module Scraping
-
module HtmlCleaners
-
# Base HTML cleaner for extracting main content from job board pages.
-
#
-
# Subclasses can override selectors and behavior for specific job boards.
-
# This provides optimized content extraction for LLM processing.
-
class BaseCleaner
-
MAX_TOKENS = 25_000
-
CHARS_PER_TOKEN = 3
-
MIN_CONTENT_LENGTH = 100
-
-
def initialize(board_type: :unknown)
-
@board_type = board_type
-
end
-
-
# Cleans HTML content and returns optimized text for LLM
-
#
-
# @param html_content [String] Raw HTML content
-
# @return [String] Cleaned text content
-
def clean(html_content)
-
return "" if html_content.blank?
-
-
doc = Nokogiri::HTML(html_content)
-
remove_unwanted_elements(doc)
-
main_content = extract_main_content(doc)
-
text = main_content.text
-
text = normalize_whitespace(text)
-
truncate_to_token_limit(text, MAX_TOKENS)
-
end
-
-
protected
-
-
# Elements to remove (scripts, styles, navigation, etc.)
-
#
-
# @return [Array<String>] CSS selectors to remove
-
def elements_to_remove
-
[
-
"script", "style", "noscript",
-
"nav", "header", "footer",
-
"[class*='cookie']", "[id*='cookie']",
-
"[class*='popup']", "[id*='popup']",
-
"[class*='modal']", "[id*='modal']",
-
"[style*='display:none']", "[style*='display: none']", "[hidden]"
-
]
-
end
-
-
# Selectors for main content area (in priority order)
-
#
-
# @return [Array<String>] CSS selectors for main content
-
def main_content_selectors
-
[
-
"main", "article", "[role='main']",
-
".content", "#content", ".main-content",
-
"#root", "#app", "#__next",
-
"[class*='container']", "[class*='content']",
-
"body"
-
]
-
end
-
-
# Elements to preserve even if they match removal patterns
-
#
-
# @return [Array<String>] CSS selectors to preserve
-
def elements_to_preserve
-
[]
-
end
-
-
private
-
-
def remove_unwanted_elements(doc)
-
elements_to_remove.each do |selector|
-
doc.css(selector).each do |el|
-
# Don't remove if it matches a preservation selector
-
next if elements_to_preserve.any? { |p| el.matches?(p) rescue false }
-
el.remove
-
end
-
end
-
doc.xpath("//comment()").remove
-
end
-
-
def extract_main_content(doc)
-
main_content_selectors.each do |selector|
-
node = doc.css(selector).first
-
next unless node
-
next unless node.text.strip.length >= MIN_CONTENT_LENGTH
-
-
return node
-
end
-
-
# Fallback to largest div
-
body = doc.css("body").first || doc
-
find_largest_content_div(body) || body
-
end
-
-
def find_largest_content_div(parent)
-
return nil unless parent
-
-
divs = parent.css("div")
-
return nil if divs.empty?
-
-
divs.max_by { |div| div.text.strip.length }
-
end
-
-
def normalize_whitespace(text)
-
text = text.gsub(/[ \t]+/, " ")
-
text = text.gsub(/\n{3,}/, "\n\n")
-
text = text.split("\n").map(&:strip).join("\n")
-
text.strip
-
end
-
-
def truncate_to_token_limit(text, max_tokens)
-
max_chars = max_tokens * CHARS_PER_TOKEN
-
return text if text.length <= max_chars
-
-
truncated = text[0...max_chars]
-
truncated = truncated.sub(/\.[^.]*$/, ".")
-
truncated
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module HtmlCleaners
-
# Factory for creating board-specific HTML cleaners
-
#
-
# Returns specialized cleaners for known job boards, or a generic cleaner otherwise.
-
class CleanerFactory
-
CLEANER_MAP = {
-
ashbyhq: AshbyCleaner,
-
ashby: AshbyCleaner
-
# Add more as needed:
-
# greenhouse: GreenhouseCleaner,
-
# lever: LeverCleaner,
-
# workable: WorkableCleaner,
-
}.freeze
-
-
class << self
-
# Returns appropriate cleaner for the given board type
-
#
-
# @param board_type [Symbol, String, nil] The job board type
-
# @return [BaseCleaner] A cleaner instance
-
def cleaner_for(board_type)
-
return BaseCleaner.new if board_type.blank?
-
-
key = board_type.to_s.downcase.to_sym
-
cleaner_class = CLEANER_MAP[key] || BaseCleaner
-
cleaner_class.new
-
end
-
-
# Returns a cleaner based on URL detection
-
#
-
# @param url [String] The job listing URL
-
# @return [BaseCleaner] A cleaner instance
-
def cleaner_for_url(url)
-
return BaseCleaner.new if url.blank?
-
-
detector = Scraping::JobBoardDetectorService.new(url)
-
cleaner_for(detector.detect)
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
# Service for idempotent HTML fetching with caching
-
#
-
# Fetches HTML content from URLs and caches it in ScrapedJobListingData
-
# to avoid repeated network requests. Respects validity periods and
-
# enables retries without re-fetching.
-
#
-
# @example
-
# fetcher = Scraping::HtmlFetcherService.new(job_listing, scraping_attempt)
-
# result = fetcher.call
-
# if result[:success]
-
# html_content = result[:html_content]
-
# cached_data = result[:cached_data]
-
# end
-
class HtmlFetcherService < ApplicationService
-
include Concerns::Loggable
-
-
attr_reader :job_listing, :scraping_attempt, :url
-
-
# Initialize the HTML fetcher
-
#
-
# @param [JobListing] job_listing The job listing
-
# @param [ScrapingAttempt, nil] scraping_attempt Optional scraping attempt
-
def initialize(job_listing, scraping_attempt: nil)
-
@job_listing = job_listing
-
@scraping_attempt = scraping_attempt
-
@url = job_listing.url
-
end
-
-
# Fetches HTML content (from cache or network)
-
#
-
# @return [Hash] Result hash with success status, html_content, and cached_data
-
def call
-
return error_result("URL is required") if @url.blank?
-
-
log_event("html_fetch_started")
-
-
# Check for valid cached HTML first
-
cached_data = find_valid_cache
-
-
if cached_data
-
log_event("html_fetch_succeeded", {
-
from_cache: true,
-
valid_until: cached_data.valid_until.iso8601
-
})
-
return success_result(
-
html_content: cached_data.html_content,
-
cleaned_html: cached_data.cleaned_html,
-
cached_data: cached_data,
-
from_cache: true
-
)
-
end
-
-
# No valid cache, fetch from network
-
fetch_from_network
-
end
-
-
private
-
-
# Finds valid cached HTML for the URL
-
#
-
# @return [ScrapedJobListingData, nil] Cached data or nil
-
def find_valid_cache
-
ScrapedJobListingData.find_valid_for_url(@url, job_listing: @job_listing)
-
end
-
-
# Fetches HTML from network and caches it
-
#
-
# @return [Hash] Result hash
-
def fetch_from_network
-
start_time = Time.current
-
response = HTTParty.get(
-
@url,
-
headers: {
-
"User-Agent" => "GleaniaBot/1.0 (+https://gleania.com/bot)",
-
"Accept" => "text/html",
-
"Accept-Language" => "en-US,en;q=0.9"
-
},
-
timeout: 30,
-
open_timeout: 10,
-
follow_redirects: true,
-
max_redirects: 3
-
)
-
-
duration = Time.current - start_time
-
-
if response.success?
-
# Save to cache
-
cached_data = save_to_cache(response, duration)
-
-
log_event("html_fetch_succeeded", {
-
from_cache: false,
-
http_status: response.code,
-
duration_seconds: duration
-
})
-
-
success_result(
-
html_content: response.body,
-
cleaned_html: cached_data.cleaned_html,
-
cached_data: cached_data,
-
from_cache: false,
-
http_status: response.code
-
)
-
else
-
log_event("html_fetch_failed", {
-
error: "HTTP #{response.code}: Failed to fetch HTML",
-
http_status: response.code
-
})
-
error_result("HTTP #{response.code}: Failed to fetch HTML", http_status: response.code)
-
end
-
rescue Timeout::Error => e
-
log_error("HTML fetch timeout", e)
-
error_result("Request timeout: #{e.message}")
-
rescue => e
-
log_error("HTML fetch failed", e)
-
notify_error(
-
e,
-
context: "html_fetch",
-
severity: "error",
-
url: @url,
-
job_listing_id: @job_listing.id
-
)
-
error_result("Failed to fetch HTML: #{e.message}")
-
end
-
-
# Saves HTML content to cache
-
#
-
# @param [HTTParty::Response] response The HTTP response
-
# @param [Float] duration Fetch duration in seconds
-
# @return [ScrapedJobListingData] The cached data
-
def save_to_cache(response, duration)
-
metadata = {
-
fetched_at: Time.current.iso8601,
-
fetched_via: "http",
-
fetch_mode: "static",
-
rendered: false,
-
duration_seconds: duration,
-
content_length: response.body.length,
-
headers: response.headers.to_h.slice("content-type", "content-encoding", "last-modified")
-
}
-
-
# Use board-specific cleaner if available, otherwise fall back to generic
-
cleaner = Scraping::HtmlCleaners::CleanerFactory.cleaner_for_url(@url)
-
cleaned_html = cleaner.clean(response.body)
-
-
ScrapedJobListingData.create_with_html(
-
url: @url,
-
html_content: response.body,
-
job_listing: @job_listing,
-
scraping_attempt: @scraping_attempt,
-
http_status: response.code,
-
metadata: metadata
-
)
-
end
-
-
# Returns a success result hash
-
#
-
# @param [Hash] data Additional data
-
# @return [Hash] Success result
-
def success_result(data = {})
-
{
-
success: true,
-
html_content: data[:html_content],
-
cleaned_html: data[:cleaned_html],
-
cached_data: data[:cached_data],
-
from_cache: data[:from_cache] || false,
-
http_status: data[:http_status]
-
}
-
end
-
-
# Returns an error result hash
-
#
-
# @param [String] error_message The error message
-
# @param [Hash] additional_data Additional error data
-
# @return [Hash] Error result
-
def error_result(error_message, additional_data = {})
-
{
-
success: false,
-
error: error_message,
-
html_content: nil,
-
cached_data: nil,
-
from_cache: false
-
}.merge(additional_data)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
require "nokogiri"
-
-
module Scraping
-
# Service for extracting structured job listing data from HTML using Nokogiri
-
#
-
# Extracts basic fields from HTML using CSS selectors and text analysis
-
# to find common job board patterns. This is a fast, cheap extraction method
-
# that runs before expensive API/AI extraction.
-
#
-
# @example
-
# extractor = Scraping::HtmlScrapingService.new
-
# data = extractor.extract(html_content, url)
-
# job_listing.update(data)
-
class HtmlScrapingService
-
include Concerns::Loggable
-
-
# Field extraction configurations with selectors in priority order
-
FIELD_SELECTORS = {
-
title: [
-
"h1.job-title",
-
"[data-job-title]",
-
"[class*='job-title']",
-
"[id*='job-title']",
-
"h1",
-
".title",
-
"[class*='title']"
-
],
-
location: [
-
"[data-location]",
-
"[class*='location']",
-
"[id*='location']",
-
"address",
-
".location",
-
"[class*='address']"
-
],
-
company_name: [
-
"[data-company]",
-
"[class*='company']",
-
"[id*='company']",
-
".company",
-
".company-name",
-
"[itemprop='name']"
-
],
-
description: [
-
"[data-description]",
-
"[class*='description']",
-
"[id*='description']",
-
".description",
-
".job-description",
-
"main p",
-
"article p",
-
"[role='main'] p"
-
],
-
about_company: [
-
"[data-about]",
-
"[id*='about']",
-
"[class*='about']",
-
".about",
-
".about-us",
-
".company-about"
-
],
-
company_culture: [
-
"[data-culture]",
-
"[id*='culture']",
-
"[class*='culture']",
-
"[id*='values']",
-
"[class*='values']",
-
"[id*='mission']",
-
"[class*='mission']"
-
],
-
salary: [
-
"[data-salary]",
-
"[class*='salary']",
-
"[id*='salary']",
-
".salary",
-
"[class*='compensation']"
-
]
-
}.freeze
-
-
# Initialize the HTML scraper
-
#
-
# @param [JobListing, nil] job_listing Optional job listing for logging context
-
# @param [ScrapingAttempt, nil] scraping_attempt Optional scraping attempt for logging context
-
def initialize(job_listing: nil, scraping_attempt: nil, fetch_mode: nil, board_type: nil, extractor_kind: "generic_html_scraping", run_context: "orchestrator")
-
@job_listing = job_listing
-
@scraping_attempt = scraping_attempt
-
@url = job_listing&.url
-
@field_results = {}
-
@selectors_tried = {}
-
@fetch_mode = fetch_mode
-
@board_type = board_type
-
@extractor_kind = extractor_kind
-
@run_context = run_context
-
end
-
-
# Extracts structured data from HTML
-
#
-
# @param [String] html_content The HTML content
-
# @param [String] url The job listing URL (for logging context)
-
# @return [Hash] Extracted data hash
-
def extract(html_content, url = nil)
-
return {} if html_content.blank?
-
-
@url ||= url
-
@start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
-
@html_size = html_content.bytesize
-
@cleaned_html_size = Scraping::NokogiriHtmlCleanerService.new.clean(html_content).bytesize
-
-
log_event("html_scraping_started")
-
-
doc = Nokogiri::HTML(html_content)
-
-
# Clean up cookie banners first
-
remove_cookie_banners(doc)
-
-
result = {
-
title: extract_with_tracking(:title, doc) { extract_title(doc) },
-
location: extract_with_tracking(:location, doc) { extract_location(doc) },
-
remote_type: extract_with_tracking(:remote_type, doc) { extract_remote_type(doc) },
-
salary_min: nil,
-
salary_max: nil,
-
salary_currency: nil,
-
description: extract_with_tracking(:description, doc) { extract_description(doc) },
-
company_name: extract_with_tracking(:company_name, doc) { extract_company_name(doc) },
-
about_company: extract_with_tracking(:about_company, doc) { extract_about_company(doc) },
-
company_culture: extract_with_tracking(:company_culture, doc) { extract_company_culture(doc) },
-
job_role_title: nil
-
}
-
-
# Handle salary separately (multiple fields from one extraction)
-
salary_data = extract_with_tracking(:salary, doc) { extract_salary_data(doc) }
-
if salary_data.is_a?(Hash)
-
result[:salary_min] = salary_data[:min]
-
result[:salary_max] = salary_data[:max]
-
result[:salary_currency] = salary_data[:currency]
-
end
-
-
# Job role title comes from title
-
result[:job_role_title] = result[:title]
-
-
# Remove nil values
-
result.compact!
-
-
# Calculate duration
-
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
-
@duration_ms = ((end_time - @start_time) * 1000).round
-
-
# Create the log record
-
create_scraping_log(result)
-
-
if result.any?
-
log_event("html_scraping_succeeded", {
-
fields_extracted: result.keys,
-
extraction_rate: calculate_extraction_rate,
-
duration_ms: @duration_ms,
-
title: result[:title],
-
location: result[:location]
-
})
-
else
-
log_event("html_scraping_no_data_extracted", {
-
duration_ms: @duration_ms,
-
selectors_tried: @selectors_tried.keys
-
})
-
end
-
-
result
-
rescue => e
-
log_error("HTML scraping failed", e)
-
create_scraping_log({}, error: e)
-
{}
-
end
-
-
private
-
-
# Removes cookie banners and consent dialogs from the document
-
#
-
# @param [Nokogiri::HTML::Document] doc The parsed HTML
-
def remove_cookie_banners(doc)
-
doc.css("div[id*='cookie'], div[class*='cookie'], div[id*='consent'], div[class*='consent'], div[id*='gdpr'], div[class*='gdpr']").remove
-
end
-
-
# Wraps field extraction with tracking
-
#
-
# @param [Symbol] field_name The field being extracted
-
# @param [Nokogiri::HTML::Document] doc The parsed HTML
-
# @yield The extraction block
-
# @return [Object] The extracted value
-
def extract_with_tracking(field_name, doc)
-
@selectors_tried[field_name] = []
-
-
value = yield
-
-
@field_results[field_name] = {
-
"success" => value.present?,
-
"value" => truncate_value(value),
-
"selectors_tried" => @selectors_tried[field_name]
-
}
-
-
if value.present? && @selectors_tried[field_name].any?
-
@field_results[field_name]["selector"] = @selectors_tried[field_name].last
-
end
-
-
value
-
end
-
-
# Truncates a value for storage
-
#
-
# @param [Object] value The value to truncate
-
# @return [Object] Truncated value
-
def truncate_value(value)
-
case value
-
when String
-
value.length > 500 ? value[0...500] + "..." : value
-
when Hash
-
value.transform_values { |v| truncate_value(v) }
-
else
-
value
-
end
-
end
-
-
# Calculates extraction rate
-
#
-
# @return [Float] Extraction rate between 0 and 1
-
def calculate_extraction_rate
-
return 0.0 if @field_results.empty?
-
-
successful = @field_results.count { |_, v| v["success"] }
-
successful.to_f / @field_results.count
-
end
-
-
# Creates the HtmlScrapingLog record
-
#
-
# @param [Hash] result The extraction result
-
# @param [Exception, nil] error Optional error
-
def create_scraping_log(result, error: nil)
-
return unless @scraping_attempt
-
-
domain = begin
-
URI.parse(@url).host
-
rescue
-
"unknown"
-
end
-
-
HtmlScrapingLog.create!(
-
scraping_attempt: @scraping_attempt,
-
job_listing: @job_listing,
-
url: @url,
-
domain: domain,
-
html_size: @html_size,
-
cleaned_html_size: @cleaned_html_size,
-
duration_ms: @duration_ms,
-
field_results: @field_results,
-
selectors_tried: @selectors_tried,
-
fetch_mode: @fetch_mode,
-
board_type: @board_type,
-
extractor_kind: @extractor_kind,
-
run_context: @run_context,
-
error_type: error&.class&.name,
-
error_message: error&.message
-
)
-
rescue => e
-
Rails.logger.error("Failed to create HtmlScrapingLog: #{e.message}")
-
end
-
-
# Extracts job title
-
#
-
# @param [Nokogiri::HTML::Document] doc The parsed HTML
-
# @return [String, nil] Job title
-
def extract_title(doc)
-
# Common cookie banner text patterns to skip
-
cookie_patterns = [
-
/select which cookies/i,
-
/accept.*cookies/i,
-
/cookie.*preferences/i,
-
/manage.*cookies/i,
-
/cookie.*consent/i
-
]
-
-
FIELD_SELECTORS[:title].each do |selector|
-
@selectors_tried[:title] << selector
-
element = doc.css(selector).first
-
if element
-
title = element.text.strip
-
# Skip if it looks like cookie banner text
-
next if cookie_patterns.any? { |pattern| title.match?(pattern) }
-
return title if title.present? && title.length < 200 && title.length > 3
-
end
-
end
-
-
nil
-
end
-
-
# Extracts location
-
#
-
# @param [Nokogiri::HTML::Document] doc The parsed HTML
-
# @return [String, nil] Location
-
def extract_location(doc)
-
FIELD_SELECTORS[:location].each do |selector|
-
@selectors_tried[:location] << selector
-
element = doc.css(selector).first
-
if element
-
location = element.text.strip
-
return location if location.present? && location.length < 200
-
end
-
end
-
-
# Try to find location in text content
-
@selectors_tried[:location] << "text_pattern_search"
-
text = doc.text
-
location_patterns = [
-
/(?:Location|Location:)\s*([A-Z][^,\n]{2,50}(?:,\s*[A-Z]{2})?)/i,
-
/([A-Z][^,\n]{2,50},\s*[A-Z]{2})/,
-
/(Remote|Hybrid|On-site|Onsite)/i
-
]
-
-
location_patterns.each do |pattern|
-
match = text.match(pattern)
-
return match[1].strip if match && match[1]
-
end
-
-
nil
-
end
-
-
# Infers remote type from content
-
#
-
# @param [Nokogiri::HTML::Document] doc The parsed HTML
-
# @return [Symbol, nil] :remote, :hybrid, or :on_site
-
def extract_remote_type(doc)
-
@selectors_tried[:remote_type] ||= []
-
@selectors_tried[:remote_type] << "text_pattern_search"
-
-
text = doc.text.downcase
-
-
# Check for explicit remote indicators
-
if text.match?(/\b(remote|work from home|wfh|distributed|anywhere)\b/i)
-
return :remote
-
end
-
-
# Check for hybrid indicators
-
if text.match?(/\b(hybrid|flexible|partially remote)\b/i)
-
return :hybrid
-
end
-
-
# Check for on-site indicators
-
if text.match?(/\b(on.?site|on.?premise|in.?office|in.?person)\b/i)
-
return :on_site
-
end
-
-
nil
-
end
-
-
# Extracts salary information
-
#
-
# @param [Nokogiri::HTML::Document] doc The parsed HTML
-
# @return [Hash] Salary data with :min, :max, :currency
-
def extract_salary_data(doc)
-
# First try salary-specific selectors (best signal).
-
salary_text = nil
-
salary_context = nil
-
FIELD_SELECTORS[:salary].each do |selector|
-
@selectors_tried[:salary] ||= []
-
@selectors_tried[:salary] << selector
-
element = doc.css(selector).first
-
next unless element
-
-
salary_text = element.text.to_s
-
salary_context = salary_text
-
break
-
end
-
-
# Conservative fallback: only scan lines that likely relate to compensation.
-
if salary_text.nil?
-
@selectors_tried[:salary] << "text_pattern_search"
-
salary_text = compensation_candidate_text(doc.text.to_s)
-
salary_context = salary_text
-
end
-
-
return {} if salary_text.blank?
-
-
parsed = parse_salary_from_text(salary_text)
-
return {} unless parsed
-
-
normalized = Scraping::SalaryRangeValidator.normalize(
-
min: parsed[:min],
-
max: parsed[:max],
-
currency: parsed[:currency],
-
context_text: salary_context
-
)
-
-
return {} unless normalized[:valid]
-
-
{ min: normalized[:min], max: normalized[:max], currency: normalized[:currency] }
-
end
-
-
# Extracts only the lines most likely to contain compensation.
-
#
-
# This prevents false positives like "89 - 7" pulled from unrelated prose.
-
#
-
# @param text [String]
-
# @return [String]
-
def compensation_candidate_text(text)
-
return "" if text.blank?
-
-
lines = text.to_s.split(/\r?\n/).map(&:strip).reject(&:blank?)
-
return "" if lines.empty?
-
-
needle = /\b(salary|compensation|pay|remuneration|total\s+comp|ote|base)\b|[$€£]|\b(usd|eur|gbp|pln|chf|cad|aud)\b/i
-
picked = lines.select { |l| l.match?(needle) }
-
picked.first(15).join("\n")
-
end
-
-
def parse_salary_from_text(text)
-
return nil if text.blank?
-
-
# Require some "money signal" in the text to avoid matching arbitrary numbers.
-
money_signal = /\b(salary|compensation|pay|remuneration|total\s+comp|ote|base)\b|[$€£]|\b(usd|eur|gbp|pln|chf|cad|aud)\b/i
-
return nil unless text.match?(money_signal)
-
-
# Range patterns (support k and decimals).
-
range_patterns = [
-
/(?<cur>[$€£])?\s*(?<min>\d[\d\s,\.]*\d\s*[kK]?)\s*(?:-|–|—|\bto\b)\s*(?<cur2>[$€£])?\s*(?<max>\d[\d\s,\.]*\d\s*[kK]?)\s*(?<code>[A-Z]{3})?/i,
-
/(?<min>\d[\d\s,\.]*\d\s*[kK]?)\s*(?<code>[A-Z]{3})\s*(?:-|–|—|\bto\b)\s*(?<max>\d[\d\s,\.]*\d\s*[kK]?)/i
-
]
-
-
range_patterns.each do |re|
-
m = text.match(re)
-
next unless m
-
-
currency = currency_from_match(m)
-
return nil if currency.blank?
-
-
return {
-
min: m[:min],
-
max: m[:max],
-
currency: currency
-
}
-
end
-
-
# Single "min+" pattern. We still require a currency signal.
-
single_re = /(?<cur>[$€£])?\s*(?<min>\d[\d\s,\.]*\d\s*[kK]?)\s*\+\s*(?<code>[A-Z]{3})?/i
-
m = text.match(single_re)
-
return nil unless m
-
-
currency = currency_from_match(m)
-
return nil if currency.blank?
-
-
{
-
min: m[:min],
-
max: nil,
-
currency: currency
-
}
-
end
-
-
def currency_from_match(match)
-
code = match[:code].to_s.strip.upcase.presence
-
return code if code.present?
-
-
symbol = (match[:cur] || match[:cur2]).to_s.strip
-
case symbol
-
when "$" then "USD"
-
when "€" then "EUR"
-
when "£" then "GBP"
-
else
-
nil
-
end
-
end
-
-
# Extracts job description summary
-
#
-
# @param [Nokogiri::HTML::Document] doc The parsed HTML
-
# @return [String, nil] Description summary
-
def extract_description(doc)
-
FIELD_SELECTORS[:description].each do |selector|
-
@selectors_tried[:description] << selector
-
elements = doc.css(selector)
-
next if elements.empty?
-
-
# Get first paragraph or concatenate first few
-
text = elements.first(3).map(&:text).join("\n\n").strip
-
return text[0...2000] if text.present? # Limit to 2000 chars
-
end
-
-
nil
-
end
-
-
# Extracts company name
-
#
-
# @param [Nokogiri::HTML::Document] doc The parsed HTML
-
# @return [String, nil] Company name
-
def extract_company_name(doc)
-
FIELD_SELECTORS[:company_name].each do |selector|
-
@selectors_tried[:company_name] << selector
-
element = doc.css(selector).first
-
if element
-
name = element["content"] || element["alt"] || element.text
-
name = name.to_s.strip
-
return name if name.present? && name.length < 100
-
end
-
end
-
-
# Try meta tags
-
@selectors_tried[:company_name] << "meta_tags"
-
meta_company = doc.css("meta[property='og:site_name'], meta[name='company']").first
-
if meta_company
-
name = meta_company["content"] || meta_company["value"]
-
return name.strip if name.present?
-
end
-
-
nil
-
end
-
-
def extract_about_company(doc)
-
text = extract_block_from_selectors(doc, :about_company, max_chars: 2000)
-
return text if text.present?
-
-
extract_section_by_heading(doc, /about\s+(the\s+)?company|about\s+us|who\s+we\s+are/i, max_chars: 2000)
-
end
-
-
def extract_company_culture(doc)
-
text = extract_block_from_selectors(doc, :company_culture, max_chars: 2000)
-
return text if text.present?
-
-
extract_section_by_heading(doc, /culture|values|mission|principles|how\s+we\s+work/i, max_chars: 2000)
-
end
-
-
def extract_block_from_selectors(doc, field, max_chars:)
-
FIELD_SELECTORS[field].each do |selector|
-
@selectors_tried[field] << selector
-
element = doc.css(selector).first
-
next unless element
-
-
text = element.text.to_s.squish
-
return text[0...max_chars] if text.present?
-
end
-
-
nil
-
end
-
-
def extract_section_by_heading(doc, heading_regex, max_chars:)
-
headings = doc.css("h1, h2, h3, h4, strong, b")
-
headings.each do |heading|
-
next unless heading.text.to_s.squish.match?(heading_regex)
-
-
# Collect siblings until next heading-like element
-
chunks = []
-
node = heading
-
while (node = node.next_sibling)
-
break if node.element? && node.name.to_s.match?(/\Ah[1-6]\z/i)
-
next if node.text?
-
-
text = node.text.to_s.squish
-
next if text.blank?
-
-
chunks << text
-
break if chunks.join("\n\n").length >= max_chars
-
end
-
-
combined = chunks.join("\n\n").strip
-
return combined[0...max_chars] if combined.present?
-
end
-
-
nil
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
# Service for detecting job board type from URL
-
#
-
# Analyzes URLs to determine which job board or ATS platform is being used,
-
# enabling platform-specific extraction strategies.
-
#
-
# @example
-
# detector = Scraping::JobBoardDetectorService.new("https://boards.greenhouse.io/company/jobs/123")
-
# detector.detect # => :greenhouse
-
# detector.company_slug # => "company"
-
# detector.job_id # => "123"
-
class JobBoardDetectorService
-
# Board types that have API integrations
-
API_SUPPORTED_BOARDS = [ :greenhouse, :lever ].freeze
-
-
# Board types with limited extraction capability (require auth, heavy JS, etc.)
-
LIMITED_EXTRACTION_BOARDS = [ :linkedin, :indeed, :glassdoor ].freeze
-
-
# Initialize detector with a URL
-
#
-
# @param [String] url The job listing URL to analyze
-
def initialize(url)
-
@url = url
-
@uri = URI.parse(url)
-
end
-
-
# Detects the job board type from the URL
-
#
-
# @return [Symbol] Job board type (:linkedin, :greenhouse, :lever, etc.)
-
def detect
-
return :greenhouse if greenhouse?
-
return :lever if lever?
-
return :linkedin if linkedin?
-
return :indeed if indeed?
-
return :glassdoor if glassdoor?
-
return :workable if workable?
-
return :jobvite if jobvite?
-
return :icims if icims?
-
return :smartrecruiters if smartrecruiters?
-
return :bamboohr if bamboohr?
-
return :ashbyhq if ashbyhq?
-
-
:unknown
-
end
-
-
# Checks if this board type has API support
-
#
-
# @return [Boolean] True if API integration is available
-
def api_supported?
-
API_SUPPORTED_BOARDS.include?(detect)
-
end
-
-
# Checks if this board type has limited extraction capability
-
# (requires authentication, heavy JS rendering, or blocks scraping)
-
#
-
# @return [Boolean] True if extraction is limited
-
def limited_extraction?
-
LIMITED_EXTRACTION_BOARDS.include?(detect)
-
end
-
-
# Extracts company slug/identifier from URL
-
#
-
# @return [String, nil] Company identifier or nil
-
def company_slug
-
case detect
-
when :greenhouse
-
extract_greenhouse_company
-
when :lever
-
extract_lever_company
-
when :workable
-
extract_workable_company
-
else
-
nil
-
end
-
end
-
-
# Extracts job ID from URL
-
#
-
# @return [String, nil] Job ID or nil
-
def job_id
-
# Lever URLs are typically /<company>/<job-id>
-
if detect == :lever
-
segments = @uri.path.to_s.split("/").reject(&:blank?)
-
return segments[1] if segments.length >= 2
-
end
-
-
# LinkedIn has specific patterns
-
if detect == :linkedin
-
return extract_linkedin_job_id
-
end
-
-
# Try to find job ID in common patterns
-
patterns = [
-
%r{/jobs?/(\d+)}, # /jobs/123
-
%r{/positions?/(\d+)}, # /positions/123
-
%r{/careers?/(\d+)}, # /careers/123
-
%r{/job/([^/\?]+)}, # /job/some-id
-
%r{/position/([^/\?]+)}, # /position/some-id
-
%r{job_id=([^&]+)}, # ?job_id=123
-
%r{gh_jid=([^&]+)} # Greenhouse job ID param
-
]
-
-
patterns.each do |pattern|
-
match = @url.match(pattern)
-
return match[1] if match
-
end
-
-
nil
-
end
-
-
# Returns the canonical URL for this job listing
-
# Useful for normalizing different URL formats that point to the same job
-
#
-
# @return [String] Canonical URL
-
def canonical_url
-
case detect
-
when :linkedin
-
job = extract_linkedin_job_id
-
job ? "https://www.linkedin.com/jobs/view/#{job}" : @url
-
else
-
@url
-
end
-
end
-
-
private
-
-
# Checks if URL is from Greenhouse
-
def greenhouse?
-
@uri.host&.include?("greenhouse.io") ||
-
@url.include?("boards.greenhouse.io") ||
-
@url.include?("gh_jid=")
-
end
-
-
# Checks if URL is from Lever
-
def lever?
-
@uri.host&.include?("lever.co") ||
-
@url.include?("jobs.lever.co")
-
end
-
-
# Checks if URL is from LinkedIn
-
def linkedin?
-
@uri.host&.include?("linkedin.com")
-
end
-
-
# Extracts job ID from LinkedIn URL
-
# Handles multiple formats:
-
# - /jobs/view/123456789
-
# - /jobs/collections/recommended/?currentJobId=123456789
-
# - /jobs/search/?currentJobId=123456789
-
#
-
# @return [String, nil] Job ID or nil
-
def extract_linkedin_job_id
-
# Direct job view: /jobs/view/123456789
-
view_match = @url.match(%r{/jobs/view/(\d+)})
-
return view_match[1] if view_match
-
-
# Collection/search with currentJobId param
-
param_match = @url.match(/currentJobId=(\d+)/)
-
return param_match[1] if param_match
-
-
nil
-
end
-
-
# Checks if URL is from Indeed
-
def indeed?
-
@uri.host&.include?("indeed.com")
-
end
-
-
# Checks if URL is from Glassdoor
-
def glassdoor?
-
@uri.host&.include?("glassdoor.com")
-
end
-
-
# Checks if URL is from Workable
-
def workable?
-
@uri.host&.include?("workable.com") ||
-
@url.include?("apply.workable.com")
-
end
-
-
# Checks if URL is from Jobvite
-
def jobvite?
-
@uri.host&.include?("jobvite.com")
-
end
-
-
# Checks if URL is from iCIMS
-
def icims?
-
@uri.host&.include?("icims.com")
-
end
-
-
# Checks if URL is from SmartRecruiters
-
def smartrecruiters?
-
@uri.host&.include?("smartrecruiters.com")
-
end
-
-
# Checks if URL is from BambooHR
-
def bamboohr?
-
@uri.host&.include?("bamboohr.com")
-
end
-
-
# Checks if URL is from Ashby
-
def ashbyhq?
-
@uri.host&.include?("ashbyhq.com") ||
-
@uri.host&.include?("jobs.ashbyhq.com")
-
end
-
-
# Extracts company slug from Greenhouse URL
-
# Example: https://boards.greenhouse.io/company-name/jobs/123
-
def extract_greenhouse_company
-
match = @url.match(%r{boards\.greenhouse\.io/([^/]+)})
-
match ? match[1] : nil
-
end
-
-
# Extracts company slug from Lever URL
-
# Example: https://jobs.lever.co/company-name/job-id
-
def extract_lever_company
-
match = @url.match(%r{jobs\.lever\.co/([^/]+)})
-
match ? match[1] : nil
-
end
-
-
# Extracts company slug from Workable URL
-
# Example: https://apply.workable.com/company-name/
-
def extract_workable_company
-
match = @url.match(%r{apply\.workable\.com/([^/]+)})
-
match ? match[1] : nil
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module JobBoards
-
# Extractor for Ashby job board pages (jobs.ashbyhq.com)
-
#
-
# Ashby uses React with dynamically generated class names like `_title_ud4nd_34`
-
# but also has stable semantic classes like `ashby-job-posting-heading`.
-
#
-
# Page structure:
-
# - Header: Company logo with alt text, back button
-
# - Left pane (.ashby-job-posting-left-pane): Location, Employment Type, Location Type, Department
-
# - Right pane (.ashby-job-posting-right-pane): Full job description with h2 sections
-
# - Title in h1 with class containing "_title_" or .ashby-job-posting-heading
-
# - Company name: In title tag after "@" or in logo img[alt]
-
#
-
# NOTE: This extractor focuses on metadata extraction (title, company, location).
-
# The job description contains structured sections (Responsibilities, Requirements)
-
# that are better parsed by AI extraction. We intentionally return only the raw
-
# description to let AI handle the structured extraction.
-
class AshbyExtractor < BaseExtractor
-
protected
-
-
def title_selectors
-
[
-
".ashby-job-posting-heading",
-
"h1[class*='_title_']",
-
"h1",
-
"meta[property='og:title']"
-
]
-
end
-
-
def company_selectors
-
[
-
# Company name is in the logo alt text
-
".ashby-job-posting-header img[alt]",
-
"[class*='_navLogoWordmarkImage_']",
-
# Or extract from title tag pattern "Job Title @ Company"
-
"title"
-
]
-
end
-
-
def location_selectors
-
[
-
# Left pane sections have Location heading
-
".ashby-job-posting-left-pane [class*='_section_']:first-of-type p",
-
"[class*='_section_'] p",
-
"meta[property='og:locale']"
-
]
-
end
-
-
def description_selectors
-
[
-
".ashby-job-posting-right-pane",
-
"[class*='_details_']",
-
"[class*='_content_']",
-
"meta[name='description']"
-
]
-
end
-
-
# Ashby embeds about_company in the description - don't duplicate
-
# Let AI extraction parse this from the description content
-
def about_company_selectors
-
[]
-
end
-
-
# Requirements are in h2 sections within the description
-
# Let AI extraction parse these from the description content
-
def requirements_selectors
-
[]
-
end
-
-
# Responsibilities are in h2 sections within the description
-
# Let AI extraction parse these from the description content
-
def responsibilities_selectors
-
[]
-
end
-
-
# Ashby has company culture info embedded in description
-
def company_culture_selectors
-
[]
-
end
-
-
# Override confidence calculation for Ashby
-
#
-
# Ashby HTML extraction only gives us metadata (title, company, location)
-
# and raw description. The structured fields (requirements, responsibilities)
-
# need AI extraction to parse from the description content.
-
#
-
# Cap confidence at 0.65 to ensure AI extraction runs for structured parsing.
-
def confidence_for(data)
-
base_score = super(data)
-
-
# If we only have basic metadata, cap confidence to trigger AI extraction
-
has_structured_data = data[:requirements].present? || data[:responsibilities].present?
-
return base_score if has_structured_data
-
-
# Cap at 0.65 to ensure AI extraction runs for structured parsing
-
[ base_score, 0.65 ].min
-
end
-
-
private
-
-
# Override pick_text to handle special cases for Ashby
-
def pick_text(doc, selectors, selectors_tried, field)
-
selectors_tried[field.to_s] = []
-
-
selectors.each do |selector|
-
selectors_tried[field.to_s] << selector
-
node = doc.css(selector).first
-
next unless node
-
-
# Handle special extraction for company from title tag
-
if field == :company_name && selector == "title"
-
title_text = node.text.to_s
-
# Extract company from "Job Title @ Company" pattern
-
if title_text.include?("@")
-
company = title_text.split("@").last&.strip
-
return company if company.present?
-
end
-
next
-
end
-
-
# Handle img alt attribute for company logo
-
if node.name == "img" && node["alt"].present?
-
return node["alt"].strip
-
end
-
-
# Handle meta tags
-
raw = node["content"] || node["alt"] || node["aria-label"] || node["title"] || node.text
-
text = raw.to_s.squish
-
-
# For short fields (location), accept shorter values
-
# For long fields (description), require more content
-
min_length = case field
-
when :description
-
50
-
else
-
1 # Accept any non-empty value for short fields
-
end
-
-
return text if text.present? && text.length >= min_length
-
end
-
nil
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module JobBoards
-
class BambooHrExtractor < BaseExtractor
-
protected
-
-
def title_selectors
-
[
-
"h1",
-
"[class*='BambooHR'] h1",
-
"[data-automation-id='jobPostingHeader']"
-
]
-
end
-
-
def location_selectors
-
[
-
"[class*='location']",
-
"[data-automation-id='jobPostingLocation']"
-
]
-
end
-
-
def description_selectors
-
[
-
"[class*='jobDescription']",
-
"[class*='description']",
-
"main"
-
]
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
require "nokogiri"
-
-
module Scraping
-
module JobBoards
-
# Base extractor for job-board HTML pages using selectors-first strategy.
-
#
-
# Subclasses should provide selector lists for key fields.
-
class BaseExtractor
-
REQUIRED_FIELDS = %i[title company_name description].freeze
-
IMPORTANT_FIELDS = %i[location requirements responsibilities benefits].freeze
-
-
attr_reader :board_type
-
-
def initialize(board_type:)
-
@board_type = board_type
-
end
-
-
# Extracts structured data from HTML
-
#
-
# @param html_content [String]
-
# @return [Hash] normalized extraction result
-
def extract(html_content)
-
return failure("No HTML provided") if html_content.blank?
-
-
doc = Nokogiri::HTML(html_content)
-
selectors_tried = {}
-
-
data = {
-
title: pick_text(doc, title_selectors, selectors_tried, :title),
-
company_name: pick_text(doc, company_selectors, selectors_tried, :company_name),
-
location: pick_text(doc, location_selectors, selectors_tried, :location),
-
description: pick_text(doc, description_selectors, selectors_tried, :description),
-
about_company: pick_text(doc, about_company_selectors, selectors_tried, :about_company),
-
company_culture: pick_text(doc, company_culture_selectors, selectors_tried, :company_culture),
-
requirements: pick_text(doc, requirements_selectors, selectors_tried, :requirements),
-
responsibilities: pick_text(doc, responsibilities_selectors, selectors_tried, :responsibilities)
-
}.compact
-
-
missing_fields = REQUIRED_FIELDS.reject { |f| data[f].present? }
-
confidence = confidence_for(data)
-
-
{
-
# success here means "required fields present"; acceptance is decided by orchestrator via confidence threshold.
-
success: missing_fields.empty?,
-
extractor_kind: "job_board_selectors",
-
board_type: board_type.to_s,
-
extraction_method: "html",
-
provider: board_type.to_s,
-
confidence: confidence,
-
missing_fields: missing_fields.map(&:to_s),
-
extracted_fields: data.keys.map(&:to_s),
-
selectors_tried: selectors_tried,
-
data: data
-
}
-
rescue StandardError => e
-
failure(e.message)
-
end
-
-
protected
-
-
def title_selectors
-
[ "h1" ]
-
end
-
-
def company_selectors
-
[]
-
end
-
-
def location_selectors
-
[ "[class*='location']", "[data-location]", "address" ]
-
end
-
-
def description_selectors
-
[ "[class*='description']", "[data-description]", "main", "article" ]
-
end
-
-
def requirements_selectors
-
[]
-
end
-
-
def responsibilities_selectors
-
[]
-
end
-
-
def about_company_selectors
-
[
-
"[id*='about']",
-
"[class*='about']",
-
".about",
-
".about-us"
-
]
-
end
-
-
def company_culture_selectors
-
[
-
"[id*='culture']",
-
"[class*='culture']",
-
"[id*='values']",
-
"[class*='values']",
-
"[id*='mission']",
-
"[class*='mission']"
-
]
-
end
-
-
def confidence_for(data)
-
# Weighted score emphasizing must-have fields.
-
weights = {
-
title: 0.25,
-
company_name: 0.25,
-
description: 0.15,
-
location: 0.05,
-
requirements: 0.075,
-
responsibilities: 0.075,
-
benefits: 0.05,
-
about_company: 0.05,
-
company_culture: 0.05
-
}
-
-
score = weights.sum { |field, w| data[field].present? ? w : 0.0 }
-
-
# If any required field is missing, cap confidence so we continue to AI.
-
missing_required = REQUIRED_FIELDS.any? { |f| data[f].blank? }
-
score = [ score, 0.69 ].min if missing_required
-
-
score.clamp(0.0, 1.0)
-
end
-
-
private
-
-
def pick_text(doc, selectors, selectors_tried, field)
-
selectors_tried[field.to_s] = []
-
selectors.each do |selector|
-
selectors_tried[field.to_s] << selector
-
node = doc.css(selector).first
-
next unless node
-
-
raw = node["content"] || node["alt"] || node["aria-label"] || node["title"] || node.text
-
text = raw.to_s.squish
-
return text if text.present?
-
end
-
nil
-
end
-
-
def failure(message)
-
{
-
success: false,
-
extractor_kind: "job_board_selectors",
-
board_type: board_type.to_s,
-
extraction_method: "html",
-
provider: board_type.to_s,
-
confidence: 0.0,
-
error: message,
-
missing_fields: REQUIRED_FIELDS.map(&:to_s),
-
extracted_fields: [],
-
selectors_tried: {},
-
data: {}
-
}
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module JobBoards
-
# Factory for mapping detected board types to selectors-first extractors.
-
class ExtractorFactory
-
def self.build(board_type)
-
case board_type&.to_sym
-
when :greenhouse
-
GreenhouseExtractor.new(board_type: :greenhouse)
-
when :lever
-
LeverExtractor.new(board_type: :lever)
-
when :workable
-
WorkableExtractor.new(board_type: :workable)
-
when :ashbyhq
-
AshbyExtractor.new(board_type: :ashbyhq)
-
when :smartrecruiters
-
SmartRecruitersExtractor.new(board_type: :smartrecruiters)
-
when :bamboohr
-
BambooHrExtractor.new(board_type: :bamboohr)
-
when :icims
-
IcimsExtractor.new(board_type: :icims)
-
when :jobvite
-
JobviteExtractor.new(board_type: :jobvite)
-
else
-
BaseExtractor.new(board_type: (board_type || :unknown))
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module JobBoards
-
class GreenhouseExtractor < BaseExtractor
-
protected
-
-
def title_selectors
-
[
-
"h1.app-title",
-
"h1",
-
"[data-testid='job-title']"
-
]
-
end
-
-
def company_selectors
-
[
-
".company-name",
-
"[class*='company'] a",
-
"meta[property='og:site_name']"
-
]
-
end
-
-
def location_selectors
-
[
-
"#location",
-
".location",
-
"[class*='location']"
-
]
-
end
-
-
def description_selectors
-
[
-
"#content",
-
"#job_description",
-
".content",
-
"[id*='description']",
-
"[class*='description']"
-
]
-
end
-
-
def about_company_selectors
-
[
-
"#content",
-
"[id*='about']",
-
"[class*='about']",
-
".about",
-
".about-us"
-
]
-
end
-
-
def company_culture_selectors
-
[
-
"[id*='culture']",
-
"[class*='culture']",
-
"[id*='values']",
-
"[class*='values']",
-
"[id*='mission']",
-
"[class*='mission']"
-
]
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module JobBoards
-
class IcimsExtractor < BaseExtractor
-
protected
-
-
def title_selectors
-
[
-
"h1",
-
"#header h1",
-
"[class*='iCIMS'] h1"
-
]
-
end
-
-
def location_selectors
-
[
-
"[class*='location']",
-
"[id*='location']"
-
]
-
end
-
-
def description_selectors
-
[
-
"#job-content",
-
"[id*='jobDescription']",
-
"[class*='description']",
-
"main"
-
]
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module JobBoards
-
class JobviteExtractor < BaseExtractor
-
protected
-
-
def title_selectors
-
[
-
"h1",
-
"[class*='jv-header'] h1",
-
"[class*='job-title']"
-
]
-
end
-
-
def company_selectors
-
[
-
"meta[property='og:site_name']",
-
"[class*='company']"
-
]
-
end
-
-
def location_selectors
-
[
-
"[class*='jv-job-location']",
-
"[class*='location']"
-
]
-
end
-
-
def description_selectors
-
[
-
"[class*='jv-job-detail-description']",
-
"[class*='description']",
-
"main"
-
]
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module JobBoards
-
class LeverExtractor < BaseExtractor
-
protected
-
-
def title_selectors
-
[
-
"h2.posting-headline",
-
"h1",
-
"[data-qa='posting-name']"
-
]
-
end
-
-
def company_selectors
-
[
-
".main-header-logo img[alt]",
-
"meta[property='og:site_name']"
-
]
-
end
-
-
def location_selectors
-
[
-
".posting-categories .location",
-
"[class*='location']"
-
]
-
end
-
-
def description_selectors
-
[
-
".posting-description",
-
"[class*='posting-description']",
-
"[class*='description']"
-
]
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module JobBoards
-
class SmartRecruitersExtractor < BaseExtractor
-
protected
-
-
def title_selectors
-
[
-
"h1",
-
"[data-testid='job-title']",
-
"[class*='job-title']"
-
]
-
end
-
-
def company_selectors
-
[
-
"meta[property='og:site_name']",
-
"[class*='company']"
-
]
-
end
-
-
def location_selectors
-
[
-
"[data-testid='job-location']",
-
"[class*='location']"
-
]
-
end
-
-
def description_selectors
-
[
-
"[data-testid='job-description']",
-
"[class*='job-description']",
-
"[class*='description']",
-
"main"
-
]
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module JobBoards
-
class WorkableExtractor < BaseExtractor
-
protected
-
-
def title_selectors
-
[
-
"h1[data-ui='job-title']",
-
"h1",
-
"[class*='job-title']"
-
]
-
end
-
-
def company_selectors
-
[
-
"[data-ui='company-name']",
-
"[class*='company']",
-
"meta[property='og:site_name']"
-
]
-
end
-
-
def location_selectors
-
[
-
"[data-ui='job-location']",
-
"[class*='location']"
-
]
-
end
-
-
def description_selectors
-
[
-
"[data-ui='job-description']",
-
"[class*='job-description']",
-
"[class*='description']"
-
]
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
require "nokogiri"
-
-
module Scraping
-
# Service for cleaning HTML content using Nokogiri
-
#
-
# Extracts main content, removes unwanted elements, and optimizes
-
# for LLM token limits. Uses semantic HTML5 elements and common
-
# job board patterns to find the main content area.
-
#
-
# For board-specific cleaning, use HtmlCleaners::CleanerFactory instead.
-
#
-
# @example Basic usage
-
# cleaner = Scraping::NokogiriHtmlCleanerService.new
-
# cleaned_text = cleaner.clean(html_content)
-
#
-
# @example Board-specific cleaning
-
# cleaner = Scraping::HtmlCleaners::CleanerFactory.cleaner_for(:ashbyhq)
-
# cleaned_text = cleaner.clean(html_content)
-
class NokogiriHtmlCleanerService
-
MAX_TOKENS = 25_000 # Conservative limit to stay under 30k tokens/minute
-
CHARS_PER_TOKEN = 3 # Conservative estimate: 1 token ≈ 3 chars for HTML
-
-
# Cleans HTML content and returns optimized text
-
#
-
# @param [String] html_content The raw HTML content
-
# @return [String] Cleaned text content optimized for LLM
-
def clean(html_content)
-
return "" if html_content.blank?
-
-
doc = Nokogiri::HTML(html_content)
-
-
# Remove unwanted elements first
-
remove_unwanted_elements(doc)
-
-
# Extract main content
-
main_content = extract_main_content(doc)
-
-
# Convert to text and clean up whitespace
-
text = main_content.text
-
text = normalize_whitespace(text)
-
-
# Truncate to token limit
-
truncate_to_token_limit(text, MAX_TOKENS)
-
end
-
-
private
-
-
# Removes unwanted elements from the document
-
#
-
# @param [Nokogiri::HTML::Document] doc The parsed HTML document
-
def remove_unwanted_elements(doc)
-
# Remove script and style tags
-
doc.css("script, style").remove
-
-
# Remove navigation elements
-
doc.css("nav, header, footer").remove
-
-
# Remove common ad/tracking containers
-
doc.css("[class*='ad'], [class*='advertisement'], [id*='ad'], [id*='advertisement']").remove
-
doc.css("[class*='tracking'], [class*='analytics'], [id*='tracking']").remove
-
-
# Remove social media widgets
-
doc.css("[class*='social'], [class*='share'], [id*='social'], [id*='share']").remove
-
-
# Remove comments
-
doc.xpath("//comment()").remove
-
-
# Remove hidden elements
-
doc.css("[style*='display:none'], [style*='display: none'], [hidden]").remove
-
end
-
-
# Extracts the main content area from the document
-
#
-
# @param [Nokogiri::HTML::Document] doc The parsed HTML document
-
# @return [Nokogiri::XML::Node] The main content node
-
def extract_main_content(doc)
-
# Minimum content length threshold - skip elements with too little text
-
min_content_length = 100
-
-
# Try semantic HTML5 elements first
-
main = doc.css("main, article, [role='main']").first
-
return main if main && main.text.strip.length >= min_content_length
-
-
# Try common content class names
-
content = doc.css(".content, #content, .main-content, .job-content, .job-description").first
-
return content if content && content.text.strip.length >= min_content_length
-
-
# Try React/SPA root containers (common for Ashby, Greenhouse, Lever, etc.)
-
# Check these BEFORE job-specific selectors as they usually contain the full content
-
react_root = doc.css("#root, #app, #__next").first
-
return react_root if react_root && react_root.text.strip.length >= min_content_length
-
-
# Try container patterns with dynamic class names (e.g., _container_xyz, _content_xyz)
-
container = doc.css("[class*='container'], [class*='content'], [class*='posting']").first
-
return container if container && container.text.strip.length >= min_content_length
-
-
# Try job-specific selectors (be careful - these can match small nav elements)
-
job_content = doc.css("[class*='job-description'], [class*='job-details'], [class*='job-posting'], [id*='job-description']").first
-
return job_content if job_content && job_content.text.strip.length >= min_content_length
-
-
# Try to find the largest text-containing div
-
body = doc.css("body").first || doc
-
largest_div = find_largest_content_div(body)
-
return largest_div if largest_div && largest_div.text.strip.length >= min_content_length
-
-
# Fallback to body
-
body
-
end
-
-
# Finds the div with the most text content (likely the main content area)
-
#
-
# @param [Nokogiri::XML::Node] parent The parent node to search within
-
# @return [Nokogiri::XML::Node, nil] The largest content div or nil
-
def find_largest_content_div(parent)
-
return nil unless parent
-
-
divs = parent.css("div")
-
return nil if divs.empty?
-
-
# Find the div with the most text content
-
divs.max_by { |div| div.text.strip.length }
-
end
-
-
# Normalizes whitespace in text
-
#
-
# @param [String] text The text to normalize
-
# @return [String] Normalized text
-
def normalize_whitespace(text)
-
# Replace multiple spaces with single space
-
text = text.gsub(/[ \t]+/, " ")
-
-
# Replace multiple newlines with double newline (paragraph break)
-
text = text.gsub(/\n{3,}/, "\n\n")
-
-
# Remove leading/trailing whitespace from each line
-
text = text.split("\n").map(&:strip).join("\n")
-
-
# Final cleanup
-
text.strip
-
end
-
-
# Truncates text to stay within token limit
-
#
-
# @param [String] text The text to truncate
-
# @param [Integer] max_tokens Maximum tokens allowed
-
# @return [String] Truncated text
-
def truncate_to_token_limit(text, max_tokens)
-
max_chars = max_tokens * CHARS_PER_TOKEN
-
-
return text if text.length <= max_chars
-
-
# Truncate to max length
-
truncated = text[0...max_chars]
-
-
# Try to end at a sentence boundary
-
truncated = truncated.sub(/\.[^.]*$/, ".")
-
-
# If still too long, truncate more aggressively
-
while estimate_tokens(truncated) > max_tokens && truncated.length > 10_000
-
truncated = truncated[0...(truncated.length * 0.9).to_i]
-
truncated = truncated.sub(/\.[^.]*$/, ".")
-
end
-
-
truncated
-
end
-
-
# Estimates token count for text
-
#
-
# @param [String] text The text to estimate
-
# @return [Integer] Estimated token count
-
def estimate_tokens(text)
-
(text.length.to_f / CHARS_PER_TOKEN).ceil
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module Orchestration
-
# Shared state for a scraping orchestration run.
-
class Context
-
CONFIDENCE_THRESHOLD = 0.7
-
-
attr_reader :job_listing, :attempt, :event_recorder, :started_at
-
attr_accessor :detector, :board_type, :company_slug, :job_id
-
attr_accessor :html_content, :cleaned_html, :fetch_mode
-
-
def initialize(job_listing:, attempt:, event_recorder:)
-
@job_listing = job_listing
-
@attempt = attempt
-
@event_recorder = event_recorder
-
@started_at = Time.current
-
-
@detector = nil
-
@board_type = :unknown
-
@company_slug = nil
-
@job_id = nil
-
-
@html_content = nil
-
@cleaned_html = nil
-
@fetch_mode = "static"
-
end
-
-
def confidence_threshold
-
CONFIDENCE_THRESHOLD
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module Orchestration
-
# Runs the scraping pipeline steps in order.
-
class Runner
-
def initialize(context)
-
@context = context
-
end
-
-
# @return [Boolean] true if completed successfully
-
def call
-
steps.each do |step|
-
outcome = step.call(@context)
-
return true if outcome == :stop_success
-
return false if outcome == :stop_failure
-
end
-
-
false
-
rescue => e
-
Support::AttemptLifecycle.log_error(@context, "Orchestration failed", e)
-
@context.event_recorder&.record_failure(
-
message: e.message,
-
error_type: e.class.name,
-
details: { backtrace: e.backtrace&.first(5) }
-
)
-
Support::AttemptLifecycle.fail!(@context, failed_step: "orchestration", error_message: e.message)
-
raise
-
end
-
-
private
-
-
def steps
-
[
-
Steps::DetectJobBoard.new,
-
Steps::FetchHtml.new,
-
Steps::ResolveEmbeddedJobBoard.new,
-
Steps::RenderedFallback.new,
-
Steps::HandleLimitedSources.new,
-
Steps::NokogiriScrape.new,
-
Steps::SelectorsExtract.new,
-
Steps::ApiExtract.new,
-
Steps::AiExtract.new
-
]
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
require "timeout"
-
-
module Scraping
-
module Orchestration
-
module Steps
-
class AiExtract < BaseStep
-
# Hard timeout for AI extraction to prevent indefinite hangs
-
# LLM API calls can take 30-60 seconds for large documents
-
AI_EXTRACTION_TIMEOUT_SECONDS = 120
-
-
# Custom error for AI extraction timeout
-
class AiExtractionTimeoutError < StandardError; end
-
-
def call(context)
-
context.attempt.start_extract!
-
-
ai_result = context.event_recorder.record(
-
:ai_extraction,
-
input: { html_size: context.html_content&.bytesize, cleaned_html_size: context.cleaned_html&.bytesize }
-
) do |event|
-
# Wrap AI extraction in a timeout to prevent indefinite hangs
-
result = Timeout.timeout(AI_EXTRACTION_TIMEOUT_SECONDS, AiExtractionTimeoutError) do
-
Scraping::AiJobExtractorService.new(context.job_listing, scraping_attempt: context.attempt).extract(
-
html_content: context.html_content,
-
cleaned_html: context.cleaned_html
-
)
-
end
-
event.set_output(
-
success: result[:confidence].present? && result[:confidence] >= context.confidence_threshold,
-
confidence: result[:confidence],
-
provider: result[:provider],
-
model: result[:model],
-
tokens_used: result[:tokens_used],
-
extracted_fields: result.keys.select { |k| result[k].present? },
-
error: result[:error]
-
)
-
result
-
end
-
-
confidence = ai_result&.dig(:confidence) || 0.0
-
has_useful_data = ai_result && (ai_result[:title].present? || ai_result[:company].present? || ai_result[:description].present?)
-
-
if confidence >= context.confidence_threshold
-
# High confidence - full success
-
context.event_recorder.record_simple(:data_update, status: :success, input: { source: "ai" }, output: { confidence: confidence })
-
Support::JobListingUpdater.update_final!(context, ai_result.merge(extraction_method: "ai"))
-
context.event_recorder.record_completion(summary: { method: "ai", confidence: confidence, provider: ai_result[:provider], model: ai_result[:model] })
-
Support::AttemptLifecycle.complete!(
-
context,
-
extraction_method: "ai",
-
provider: ai_result[:provider],
-
confidence: confidence,
-
model: ai_result[:model],
-
tokens_used: ai_result[:tokens_used]
-
)
-
return stop_success
-
end
-
-
# Low confidence - but still save whatever useful data we extracted
-
# This ensures title/company are updated even if overall confidence is low
-
if has_useful_data
-
context.event_recorder.record_simple(:data_update, status: :success, input: { source: "ai", partial: true }, output: { confidence: confidence })
-
Support::JobListingUpdater.update_final!(context, ai_result.merge(extraction_method: "ai"))
-
Rails.logger.info({
-
event: "low_confidence_data_saved",
-
job_listing_id: context.job_listing.id,
-
confidence: confidence,
-
extracted_fields: ai_result.keys.select { |k| ai_result[k].present? }
-
}.to_json)
-
end
-
-
context.event_recorder.record_failure(
-
message: "Low confidence: #{confidence}",
-
error_type: "low_confidence",
-
details: { confidence: confidence, data_saved: has_useful_data }
-
)
-
Support::AttemptLifecycle.fail!(context, failed_step: "ai_extraction", error_message: "Low confidence: #{confidence}")
-
stop_failure
-
rescue AiExtractionTimeoutError => e
-
Rails.logger.error("AI extraction timed out after #{AI_EXTRACTION_TIMEOUT_SECONDS}s for job_listing=#{context.job_listing.id}")
-
notify_error(
-
e,
-
context: "ai_extraction_timeout",
-
severity: "warning",
-
url: context.job_listing.url,
-
job_listing_id: context.job_listing.id,
-
timeout_seconds: AI_EXTRACTION_TIMEOUT_SECONDS
-
)
-
Support::AttemptLifecycle.fail!(context, failed_step: "ai_extraction", error_message: "AI extraction timed out after #{AI_EXTRACTION_TIMEOUT_SECONDS} seconds")
-
stop_failure
-
rescue => e
-
Support::AttemptLifecycle.log_error(context, "AI extraction failed", e)
-
notify_error(
-
e,
-
context: "ai_extraction",
-
severity: "error",
-
url: context.job_listing.url,
-
job_listing_id: context.job_listing.id
-
)
-
Support::AttemptLifecycle.fail!(context, failed_step: "ai_extraction", error_message: e.message)
-
stop_failure
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module Orchestration
-
module Steps
-
class ApiExtract < BaseStep
-
def call(context)
-
detector = context.detector
-
return continue unless detector&.api_supported? && context.company_slug.present?
-
-
unless api_population_enabled_for?(context)
-
context.event_recorder.record_skipped(:api_extraction, reason: "api_population_disabled", metadata: { board_type: context.board_type })
-
return continue
-
end
-
-
api_result = context.event_recorder.record(
-
:api_extraction,
-
input: { board_type: context.board_type, company_slug: context.company_slug, job_id: context.job_id }
-
) do |event|
-
result = fetch_api(context)
-
if result
-
event.set_output(
-
success: result[:confidence].present? && result[:confidence] >= context.confidence_threshold,
-
confidence: result[:confidence],
-
provider: context.board_type,
-
extracted_fields: result.keys
-
)
-
else
-
event.set_output(success: false, error: "No result from API")
-
end
-
result
-
end
-
-
return continue unless api_result && api_result[:confidence] && api_result[:confidence] >= context.confidence_threshold
-
-
api_result = maybe_postprocess_with_ai(context, api_result)
-
-
context.event_recorder.record_simple(:data_update, status: :success, input: { source: "api" }, output: { confidence: api_result[:confidence] })
-
Support::JobListingUpdater.update_final!(context, api_result.merge(extraction_method: "api"))
-
context.event_recorder.record_completion(summary: { method: "api", confidence: api_result[:confidence], provider: context.board_type })
-
Support::AttemptLifecycle.complete!(context, extraction_method: "api", provider: context.board_type.to_s, confidence: api_result[:confidence], model: api_result[:model], tokens_used: api_result[:tokens_used])
-
stop_success
-
rescue => e
-
Support::AttemptLifecycle.log_error(context, "API extraction failed", e)
-
notify_error(
-
e,
-
context: "api_extraction",
-
severity: "error",
-
board_type: context.board_type,
-
company_slug: context.company_slug,
-
job_id: context.job_id,
-
url: context.job_listing.url
-
)
-
continue
-
end
-
-
private
-
-
def fetch_api(context)
-
fetcher = case context.board_type.to_sym
-
when :greenhouse
-
ApiFetchers::GreenhouseFetcher.new
-
when :lever
-
ApiFetchers::LeverFetcher.new
-
else
-
nil
-
end
-
-
return nil unless fetcher
-
-
fetcher.fetch(url: context.job_listing.url, company_slug: context.company_slug, job_id: context.job_id)
-
end
-
-
def api_population_enabled_for?(context)
-
# Greenhouse "boards API" is public (no API key) and should be allowed even when
-
# generic API population is disabled due to missing credentials.
-
return true if context.board_type.to_s == "greenhouse" && Setting.greenhouse_enabled?
-
-
Setting.api_population_enabled?
-
end
-
-
def maybe_postprocess_with_ai(context, api_result)
-
return api_result unless context.board_type.to_s == "greenhouse"
-
return api_result unless api_result[:description].present?
-
-
# Only run if we have gaps that the LLM can fill (compensation/interview process/lists).
-
missing_salary = api_result[:salary_min].blank? && api_result[:salary_max].blank?
-
missing_lists = api_result[:requirements].blank? && api_result[:responsibilities].blank?
-
likely_has_comp = api_result[:description].to_s.match?(/compensation|salary|usd|eur|\$\s*\d/i)
-
-
return api_result unless missing_salary || missing_lists || likely_has_comp
-
-
post = Scraping::AiJobPostProcessorService.new(context.job_listing, scraping_attempt: context.attempt).run(
-
content_html: api_result[:description],
-
url: context.job_listing.url
-
)
-
-
return api_result if post[:confidence].to_f <= 0.0
-
-
custom_sections = (api_result[:custom_sections] || {}).merge(
-
"job_markdown" => post[:job_markdown].presence,
-
"compensation_text" => post[:compensation_text].presence,
-
"interview_process" => post[:interview_process].presence
-
).compact
-
-
{
-
**api_result,
-
salary_min: post[:salary_min].presence || api_result[:salary_min],
-
salary_max: post[:salary_max].presence || api_result[:salary_max],
-
salary_currency: post[:salary_currency].presence || api_result[:salary_currency],
-
requirements: bullets_to_text(post[:requirements_bullets]).presence || api_result[:requirements],
-
responsibilities: bullets_to_text(post[:responsibilities_bullets]).presence || api_result[:responsibilities],
-
benefits: bullets_to_text(post[:benefits_bullets]).presence || api_result[:benefits],
-
perks: bullets_to_text(post[:perks_bullets]).presence || api_result[:perks],
-
custom_sections: custom_sections
-
}
-
rescue => e
-
Support::AttemptLifecycle.log_error(context, "AI postprocess skipped", e)
-
api_result
-
end
-
-
def bullets_to_text(items)
-
arr = Array(items).map(&:to_s).map(&:strip).reject(&:blank?)
-
return "" if arr.empty?
-
arr.map { |i| "- #{i}" }.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module Orchestration
-
module Steps
-
class BaseStep < ApplicationService
-
def call(_context)
-
raise NotImplementedError
-
end
-
-
private
-
-
def continue
-
:continue
-
end
-
-
def stop_success
-
:stop_success
-
end
-
-
def stop_failure
-
:stop_failure
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module Orchestration
-
module Steps
-
class DetectJobBoard < BaseStep
-
def call(context)
-
detector = Scraping::JobBoardDetectorService.new(context.job_listing.url)
-
context.detector = detector
-
context.board_type = detector.detect
-
context.company_slug = detector.company_slug
-
context.job_id = detector.job_id
-
-
context.event_recorder.record_simple(
-
:job_board_detection,
-
status: :success,
-
input: { url: context.job_listing.url },
-
output: {
-
board_type: context.board_type,
-
company_slug: context.company_slug,
-
job_id: context.job_id,
-
api_supported: detector.api_supported?
-
}
-
)
-
-
continue
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module Orchestration
-
module Steps
-
class FetchHtml < BaseStep
-
def call(context)
-
context.attempt.start_fetch!
-
-
html_result = context.event_recorder.record(:html_fetch, input: { url: context.job_listing.url }) do |event|
-
result = Scraping::HtmlFetcherService.new(context.job_listing, scraping_attempt: context.attempt).call
-
event.set_output(
-
success: result[:success],
-
html_size: result[:html_content]&.bytesize,
-
cleaned_html_size: result[:cleaned_html]&.bytesize,
-
cached: result[:from_cache],
-
http_status: result[:http_status],
-
error: result[:error]
-
)
-
result
-
end
-
-
unless html_result[:success]
-
context.event_recorder.record_failure(message: html_result[:error], error_type: "html_fetch_failed")
-
Support::AttemptLifecycle.fail!(context, failed_step: "html_fetch", error_message: html_result[:error])
-
return stop_failure
-
end
-
-
context.html_content = html_result[:html_content]
-
context.cleaned_html = html_result[:cleaned_html]
-
context.fetch_mode = "static"
-
-
continue
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module Orchestration
-
module Steps
-
# Handles job boards with limited extraction capability
-
#
-
# For sources like LinkedIn, Indeed, Glassdoor that require authentication
-
# or heavily block scraping, this step extracts what's publicly available
-
# (mainly meta tags) and marks the extraction as limited.
-
class HandleLimitedSources < BaseStep
-
LIMITED_BOARDS = %i[linkedin indeed glassdoor].freeze
-
-
def call(context)
-
return continue unless limited_board?(context)
-
-
context.event_recorder.record(
-
:limited_source_handling,
-
input: { board_type: context.board_type, url: context.job_listing.url }
-
) do |event|
-
result = extract_meta_tags(context)
-
context.limited_extraction = true
-
-
# Update job listing with limited data
-
if result.any?
-
update_job_listing(context, result)
-
event.set_output(
-
extracted_fields: result.keys,
-
extraction_quality: "limited",
-
title: result[:title],
-
company: result[:company],
-
description_preview: result[:description]&.truncate(100)
-
)
-
else
-
event.set_output(
-
extracted_fields: [],
-
extraction_quality: "limited",
-
reason: "No meta tags found"
-
)
-
end
-
-
result
-
end
-
-
# For LinkedIn, we still try full scraping but with lower expectations
-
# The AI extraction step will handle whatever HTML we can get
-
continue
-
end
-
-
private
-
-
def limited_board?(context)
-
LIMITED_BOARDS.include?(context.board_type)
-
end
-
-
# Extracts information from meta tags (og:*, twitter:*, etc.)
-
# @return [Hash] Extracted data
-
def extract_meta_tags(context)
-
return {} if context.html_content.blank?
-
-
doc = Nokogiri::HTML(context.html_content)
-
result = {}
-
-
# Open Graph tags
-
og_title = doc.at('meta[property="og:title"]')&.[]("content")
-
og_description = doc.at('meta[property="og:description"]')&.[]("content")
-
og_image = doc.at('meta[property="og:image"]')&.[]("content")
-
og_site_name = doc.at('meta[property="og:site_name"]')&.[]("content")
-
-
# Twitter cards
-
twitter_title = doc.at('meta[name="twitter:title"]')&.[]("content")
-
twitter_description = doc.at('meta[name="twitter:description"]')&.[]("content")
-
-
# Standard meta tags
-
meta_title = doc.at("title")&.text
-
meta_description = doc.at('meta[name="description"]')&.[]("content")
-
-
# LinkedIn specific - they often have schema.org data
-
schema_data = extract_schema_org(doc)
-
-
# Build result with best available data
-
result[:title] = parse_linkedin_title(og_title || twitter_title || meta_title || schema_data[:title])
-
result[:company] = og_site_name || schema_data[:company]
-
result[:description] = og_description || twitter_description || meta_description || schema_data[:description]
-
result[:logo_url] = og_image if og_image&.include?("logo")
-
result[:location] = schema_data[:location]
-
-
result.compact
-
end
-
-
# Parses LinkedIn title which often includes " | Company" suffix
-
def parse_linkedin_title(title)
-
return nil if title.blank?
-
-
# Remove " | LinkedIn" suffix
-
title = title.gsub(/\s*\|\s*LinkedIn\s*$/i, "")
-
-
# Split on " at " or " - " to separate title from company
-
if title.include?(" at ")
-
title.split(" at ").first&.strip
-
elsif title.include?(" - ")
-
title.split(" - ").first&.strip
-
else
-
title.strip
-
end
-
end
-
-
# Extracts data from JSON-LD schema.org markup
-
def extract_schema_org(doc)
-
result = {}
-
-
doc.css('script[type="application/ld+json"]').each do |script|
-
data = JSON.parse(script.text) rescue nil
-
next unless data
-
-
# Handle arrays of schemas
-
schemas = data.is_a?(Array) ? data : [ data ]
-
-
schemas.each do |schema|
-
next unless schema.is_a?(Hash)
-
-
if schema["@type"] == "JobPosting"
-
result[:title] ||= schema["title"]
-
result[:description] ||= schema["description"]
-
result[:company] ||= schema.dig("hiringOrganization", "name")
-
result[:location] ||= schema.dig("jobLocation", "address", "addressLocality")
-
result[:salary_min] ||= schema.dig("baseSalary", "value", "minValue")
-
result[:salary_max] ||= schema.dig("baseSalary", "value", "maxValue")
-
end
-
end
-
end
-
-
result
-
end
-
-
def update_job_listing(context, result)
-
job_listing = context.job_listing
-
updates = {}
-
-
updates[:title] = result[:title] if result[:title].present? && job_listing.title.blank?
-
-
# Store extraction metadata
-
scraped_data = job_listing.scraped_data || {}
-
scraped_data["job_board"] = context.board_type.to_s
-
scraped_data["extraction_quality"] = "limited"
-
scraped_data["limited_extraction_reason"] = limited_extraction_reason(context.board_type)
-
scraped_data["meta_extraction"] = result
-
-
updates[:scraped_data] = scraped_data
-
-
job_listing.update!(updates) if updates.any?
-
-
# Try to find/create company if we have a name and current company is placeholder
-
if result[:company].present? && placeholder_company?(job_listing.company)
-
company = Company.find_or_create_by!(name: result[:company])
-
job_listing.update!(company: company)
-
end
-
end
-
-
def placeholder_company?(company)
-
return true if company.nil?
-
-
placeholder_names = [ "unknown company", "unknown" ]
-
placeholder_names.include?(company.name.to_s.downcase)
-
end
-
-
def limited_extraction_reason(board_type)
-
case board_type
-
when :linkedin
-
"LinkedIn requires authentication for full job details"
-
when :indeed
-
"Indeed limits public access to job content"
-
when :glassdoor
-
"Glassdoor requires authentication for full job details"
-
else
-
"Source has limited public access"
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module Orchestration
-
module Steps
-
class NokogiriScrape < BaseStep
-
def call(context)
-
scraping_result = context.event_recorder.record(
-
:nokogiri_scrape,
-
input: { html_size: context.html_content&.bytesize }
-
) do |event|
-
extractor = Scraping::HtmlScrapingService.new(
-
job_listing: context.job_listing,
-
scraping_attempt: context.attempt,
-
board_type: context.board_type&.to_s,
-
fetch_mode: context.fetch_mode,
-
extractor_kind: "generic_html_scraping",
-
run_context: "orchestrator"
-
)
-
result = extractor.extract(context.html_content, context.job_listing.url)
-
event.set_output(
-
extracted_fields: result.keys,
-
title: result[:title],
-
company: result[:company_name],
-
location: result[:location]
-
)
-
result
-
end
-
-
Support::JobListingUpdater.update_preliminary!(context, scraping_result) if scraping_result.any?
-
continue
-
rescue => e
-
Support::AttemptLifecycle.log_error(context, "HTML scraping failed", e)
-
continue
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module Orchestration
-
module Steps
-
class RenderedFallback < BaseStep
-
def call(context)
-
diagnosis = Support::Observability.js_heavy_diagnosis(html_content: context.html_content, cleaned_html: context.cleaned_html)
-
-
unless Setting.js_rendering_enabled?
-
context.event_recorder.record_simple(
-
:js_heavy_detected,
-
status: :skipped,
-
output: diagnosis.merge(
-
js_rendering_enabled: false,
-
triggered: false,
-
skipped_reason: "js_rendering_disabled",
-
board_type: context.board_type
-
)
-
)
-
return continue
-
end
-
-
unless diagnosis[:js_heavy]
-
context.event_recorder.record_simple(
-
:js_heavy_detected,
-
status: :skipped,
-
output: diagnosis.merge(
-
js_rendering_enabled: true,
-
triggered: false,
-
skipped_reason: "not_js_heavy",
-
board_type: context.board_type,
-
fetch_mode: context.fetch_mode
-
)
-
)
-
return continue
-
end
-
-
context.event_recorder.record_simple(
-
:js_heavy_detected,
-
status: :success,
-
output: {
-
**diagnosis,
-
js_rendering_enabled: true,
-
triggered: true,
-
board_type: context.board_type,
-
fetch_mode: context.fetch_mode
-
}
-
)
-
-
rendered_result = context.event_recorder.record(
-
:rendered_html_fetch,
-
input: { url: context.job_listing.url, board_type: context.board_type }
-
) do |event|
-
result = Scraping::RenderedHtmlFetcherService.new(context.job_listing, scraping_attempt: context.attempt).call
-
rendered_shell =
-
result[:success] &&
-
(result[:cleaned_text_length].to_i < 500 || result[:selector_found] != true)
-
-
event.set_output(
-
success: result[:success],
-
html_size: result[:html_content]&.bytesize,
-
cleaned_html_size: result[:cleaned_html]&.bytesize,
-
cleaned_text_length: result[:cleaned_text_length],
-
error: result[:error],
-
rendered: true,
-
fetch_mode: "rendered",
-
trigger_reason: diagnosis[:reason],
-
selector_found: result[:selector_found],
-
found_selectors: result[:found_selectors],
-
selector_wait_ms: result[:selector_wait_ms],
-
iframe_used: result[:iframe_used],
-
rendered_shell: rendered_shell
-
)
-
result
-
end
-
-
if rendered_result[:success]
-
context.html_content = rendered_result[:html_content]
-
context.cleaned_html = rendered_result[:cleaned_html]
-
context.fetch_mode = "rendered"
-
end
-
-
continue
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
require "uri"
-
-
module Scraping
-
module Orchestration
-
module Steps
-
# Attempts to resolve embedded job board pages into a fetchable HTML document
-
# that actually contains job content.
-
#
-
# Today this primarily targets Greenhouse "gh_jid" embeds used by many marketing sites
-
# (WordPress, Webflow, etc.) where the visible page is a shell and the job content
-
# is served from `job-boards.greenhouse.io`.
-
class ResolveEmbeddedJobBoard < BaseStep
-
GREENHOUSE_FOR_REGEX = %r{embed/job_board/js\?for=([a-zA-Z0-9_-]+)}.freeze
-
-
def call(context)
-
return continue unless context.board_type.to_s == "greenhouse"
-
-
jid = extract_query_param(context.job_listing.url, "gh_jid")
-
return continue unless jid.present?
-
-
for_key = extract_greenhouse_for_key(context.html_content)
-
return continue unless for_key.present?
-
-
# Enable downstream API extraction (Greenhouse boards API) even for marketing URLs.
-
context.company_slug ||= for_key
-
context.job_id ||= jid
-
-
embed_url = build_greenhouse_embed_url(for_key: for_key, jid: jid, source: extract_query_param(context.job_listing.url, "gh_src"))
-
-
resolved = context.event_recorder.record(
-
:embedded_job_board_fetch,
-
input: {
-
board_type: "greenhouse",
-
for_key: for_key,
-
gh_jid: jid,
-
embed_url: embed_url
-
}
-
) do |event|
-
result = fetch_html(embed_url, context)
-
event.set_output(
-
success: result[:success],
-
http_status: result[:http_status],
-
html_size: result[:html_content]&.bytesize,
-
cleaned_html_size: result[:cleaned_html]&.bytesize,
-
cleaned_text_length: extracted_text_length(result[:cleaned_html]),
-
error: result[:error],
-
fetch_mode: "greenhouse_embed"
-
)
-
result
-
end
-
-
return continue unless resolved[:success]
-
-
# Only switch if we clearly got "real" content (avoid swapping in another shell).
-
cleaned_text_length = extracted_text_length(resolved[:cleaned_html])
-
return continue if cleaned_text_length < 800
-
-
context.html_content = resolved[:html_content]
-
context.cleaned_html = resolved[:cleaned_html]
-
context.fetch_mode = "greenhouse_embed"
-
-
continue
-
rescue => e
-
context.event_recorder.record_simple(
-
:embedded_job_board_fetch,
-
status: :failed,
-
output: { error: e.message, error_type: e.class.name }
-
)
-
continue
-
end
-
-
private
-
-
def extract_greenhouse_for_key(html_content)
-
html_content.to_s[GREENHOUSE_FOR_REGEX, 1]
-
end
-
-
def build_greenhouse_embed_url(for_key:, jid:, source: nil)
-
query = { "for" => for_key, "gh_jid" => jid }
-
query["gh_src"] = source if source.present?
-
"https://job-boards.greenhouse.io/embed/job_board?#{URI.encode_www_form(query)}"
-
end
-
-
def extract_query_param(url, key)
-
uri = URI.parse(url)
-
query = uri.query.to_s
-
Rack::Utils.parse_query(query)[key]
-
rescue
-
nil
-
end
-
-
def fetch_html(url, context)
-
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
-
response = HTTParty.get(
-
url,
-
headers: {
-
"User-Agent" => Scraping::RenderedHtmlFetcherService::REALISTIC_UA,
-
"Accept" => "text/html",
-
"Accept-Language" => "en-US,en;q=0.9"
-
},
-
timeout: 30,
-
open_timeout: 10,
-
follow_redirects: true,
-
max_redirects: 3
-
)
-
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
-
-
unless response.success?
-
return { success: false, error: "HTTP #{response.code}: Failed to fetch embedded HTML", http_status: response.code }
-
end
-
-
html = response.body.to_s
-
cleaner = Scraping::HtmlCleaners::CleanerFactory.cleaner_for_url(url)
-
cleaned_html = cleaner.clean(html)
-
-
ScrapedJobListingData.create_with_html(
-
url: url,
-
html_content: html,
-
job_listing: context.job_listing,
-
scraping_attempt: context.attempt,
-
http_status: response.code,
-
metadata: {
-
fetched_via: "http",
-
fetch_mode: "greenhouse_embed",
-
rendered: false,
-
duration_ms: duration_ms,
-
embedded_from_url: context.job_listing.url
-
}
-
)
-
-
{
-
success: true,
-
html_content: html,
-
cleaned_html: cleaned_html,
-
http_status: response.code,
-
duration_ms: duration_ms
-
}
-
rescue Timeout::Error => e
-
{ success: false, error: "Embedded fetch timeout: #{e.message}" }
-
rescue => e
-
{ success: false, error: "Embedded fetch failed: #{e.message}" }
-
end
-
-
def extracted_text_length(html)
-
Nokogiri::HTML(html.to_s).text.to_s.strip.length
-
rescue
-
0
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module Orchestration
-
module Steps
-
class SelectorsExtract < BaseStep
-
def call(context)
-
return continue if context.board_type.to_sym == :unknown
-
-
selectors_result = context.event_recorder.record(
-
:selectors_extraction,
-
input: { board_type: context.board_type }
-
) do |event|
-
extractor = Scraping::JobBoards::ExtractorFactory.build(context.board_type)
-
result = extractor.extract(context.html_content)
-
event.set_output(
-
success: result[:success],
-
confidence: result[:confidence],
-
extracted_fields: result[:extracted_fields],
-
missing_fields: result[:missing_fields],
-
board_type: result[:board_type],
-
extractor_kind: result[:extractor_kind]
-
)
-
-
Support::Observability.create_selectors_html_log(
-
context,
-
result,
-
fetch_mode: context.fetch_mode,
-
board_type: context.board_type,
-
html_size: context.html_content.to_s.bytesize,
-
cleaned_html_size: context.cleaned_html.to_s.bytesize
-
)
-
-
result
-
end
-
-
return continue unless selectors_result[:success] && selectors_result[:confidence].to_f >= context.confidence_threshold
-
-
data = selectors_result[:data] || {}
-
result_for_update = data.merge(
-
extraction_method: "html",
-
provider: selectors_result[:provider] || context.board_type.to_s,
-
confidence: selectors_result[:confidence]
-
)
-
-
Support::JobListingUpdater.update_final!(context, result_for_update)
-
context.event_recorder.record_simple(:data_update, status: :success, input: { source: "selectors" }, output: { confidence: selectors_result[:confidence] })
-
Support::AttemptLifecycle.complete!(context, extraction_method: "html", provider: context.board_type.to_s, confidence: selectors_result[:confidence])
-
context.event_recorder.record_completion(summary: { method: "html", confidence: selectors_result[:confidence], provider: context.board_type })
-
stop_success
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module Orchestration
-
module Support
-
module AttemptLifecycle
-
module_function
-
-
# Creates a new scraping attempt, or returns existing recent one if still in progress
-
#
-
# Prevents duplicate attempts when:
-
# - A timeout occurs and the job is re-queued
-
# - Multiple calls happen in quick succession
-
# - An attempt just completed (no need to re-extract)
-
#
-
# @param job_listing [JobListing] The job listing to create an attempt for
-
# @param force [Boolean] Force create even if recent attempt exists
-
# @return [ScrapingAttempt, nil] New or existing attempt, or nil if recently completed
-
def create_attempt!(job_listing, force: false)
-
unless force
-
# Check for existing recent attempt that's still in progress (within last 2 minutes)
-
recent_in_progress = job_listing.scraping_attempts
-
.where(status: [ :pending, :fetching, :extracting, :retrying ])
-
.where("created_at > ?", 2.minutes.ago)
-
.order(created_at: :desc)
-
.first
-
-
if recent_in_progress
-
Rails.logger.info({
-
event: "reusing_existing_attempt",
-
job_listing_id: job_listing.id,
-
scraping_attempt_id: recent_in_progress.id,
-
status: recent_in_progress.status
-
}.to_json)
-
return recent_in_progress
-
end
-
-
# Check for recently completed attempt (within last 2 minutes)
-
# No need to re-extract if we just finished successfully
-
recent_completed = job_listing.scraping_attempts
-
.where(status: :completed)
-
.where("created_at > ?", 2.minutes.ago)
-
.order(created_at: :desc)
-
.first
-
-
if recent_completed
-
Rails.logger.info({
-
event: "skipping_attempt_recently_completed",
-
job_listing_id: job_listing.id,
-
scraping_attempt_id: recent_completed.id,
-
completed_at: recent_completed.updated_at
-
}.to_json)
-
return nil
-
end
-
end
-
-
job_listing.scraping_attempts.create!(
-
url: job_listing.url,
-
domain: extract_domain(job_listing.url),
-
status: :pending
-
)
-
end
-
-
def complete!(context, extraction_method:, provider:, confidence:, model: nil, tokens_used: nil)
-
attempt = context.attempt
-
return unless attempt
-
-
# Ensure state machine is in a completable state.
-
# Some completions happen via selectors/API without ever hitting the AI step,
-
# so we may still be in :fetching here.
-
attempt.start_fetch! if attempt.respond_to?(:may_start_fetch?) && attempt.may_start_fetch?
-
attempt.start_extract! if attempt.respond_to?(:may_start_extract?) && attempt.may_start_extract?
-
-
attempt.update(
-
extraction_method: extraction_method,
-
provider: provider,
-
confidence_score: confidence,
-
duration_seconds: Time.current - context.started_at,
-
response_metadata: {
-
model: model,
-
tokens_used: tokens_used
-
}
-
)
-
attempt.mark_completed!
-
-
log_event(context, "extraction_completed", {
-
confidence: confidence,
-
duration: Time.current - context.started_at
-
})
-
end
-
-
def fail!(context, failed_step:, error_message:)
-
attempt = context.attempt
-
return unless attempt
-
-
attempt.update(
-
failed_step: failed_step,
-
error_message: error_message,
-
duration_seconds: Time.current - context.started_at
-
)
-
attempt.mark_failed!
-
-
log_event(context, "extraction_failed", { failed_step: failed_step, error: error_message })
-
end
-
-
def log_event(context, event_name, data = {})
-
Rails.logger.info({
-
event: event_name,
-
job_listing_id: context.job_listing.id,
-
scraping_attempt_id: context.attempt&.id,
-
url: context.job_listing.url,
-
domain: extract_domain(context.job_listing.url)
-
}.merge(data).to_json)
-
end
-
-
def log_error(context, message, exception)
-
Rails.logger.error({
-
error: message,
-
exception: exception.class.name,
-
message: exception.message,
-
backtrace: exception.backtrace&.first(5),
-
job_listing_id: context.job_listing.id,
-
scraping_attempt_id: context.attempt&.id,
-
url: context.job_listing.url
-
}.to_json)
-
-
ApplicationService.new.notify_error(
-
exception,
-
context: "scraping_orchestration",
-
severity: "error",
-
error_message: message,
-
job_listing_id: context.job_listing.id,
-
scraping_attempt_id: context.attempt&.id,
-
url: context.job_listing.url
-
)
-
end
-
-
def extract_domain(url)
-
URI.parse(url).host
-
rescue
-
"unknown"
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module Orchestration
-
module Support
-
module EntityResolver
-
module_function
-
-
def find_or_create_company(context, name)
-
job_listing = context.job_listing
-
return job_listing.company if name.blank?
-
-
normalized_name = normalize_company_name(name)
-
domain = extract_domain_from_url(job_listing.url)
-
-
if job_listing.company.present?
-
existing_company = job_listing.company
-
-
if domain.present? && existing_company.website.present?
-
existing_domain = extract_domain_from_url(existing_company.website)
-
return existing_company if domains_match?(domain, existing_domain)
-
end
-
-
existing_normalized = normalize_company_name(existing_company.name)
-
return existing_company if names_similar?(normalized_name, existing_normalized)
-
end
-
-
if domain.present?
-
company = find_company_by_domain(domain)
-
return company if company
-
end
-
-
company = Company.find_by(name: normalized_name)
-
return company if company
-
-
company = find_similar_company(normalized_name)
-
return company if company
-
-
Company.create!(name: normalized_name) do |c|
-
c.website = "https://#{domain}" if domain.present?
-
end
-
end
-
-
def find_or_create_job_role(context, title, department_name: nil)
-
job_listing = context.job_listing
-
return job_listing.job_role if title.blank?
-
-
normalized_title = normalize_job_role_title(title)
-
job_role = JobRole.find_or_create_by(title: normalized_title)
-
-
# Assign department if provided and role doesn't have one
-
if department_name.present? && job_role.category_id.nil?
-
department = Category.find_by(name: department_name, kind: :job_role)
-
department ||= infer_department_from_title(normalized_title)
-
job_role.update(category: department) if department
-
elsif job_role.category_id.nil?
-
# Try to infer department from title if not provided
-
department = infer_department_from_title(normalized_title)
-
job_role.update(category: department) if department
-
end
-
-
job_role
-
end
-
-
def infer_department_from_title(title)
-
return nil if title.blank?
-
-
title_lower = title.downcase
-
-
department_keywords = {
-
"Engineering" => %w[engineer developer software backend frontend fullstack architect sre devops platform],
-
"Product" => %w[product owner manager pm],
-
"Design" => %w[designer ux ui visual graphic],
-
"Data Science" => %w[data scientist analyst analytics machine learning ml ai],
-
"DevOps/SRE" => %w[devops sre infrastructure reliability platform],
-
"Sales" => %w[sales account executive ae sdr bdr],
-
"Marketing" => %w[marketing growth seo sem content brand],
-
"Customer Success" => %w[customer success support cx],
-
"Finance" => %w[finance accounting financial controller cfo],
-
"HR/People" => %w[hr human resources people talent recruiter recruiting],
-
"Legal" => %w[legal counsel attorney compliance],
-
"Operations" => %w[operations ops logistics supply],
-
"Executive" => %w[ceo cto coo cfo cmo chief director vp president],
-
"Research" => %w[research scientist r&d],
-
"QA/Testing" => %w[qa quality assurance test tester sdet],
-
"Security" => %w[security infosec appsec cyber],
-
"IT" => %w[it helpdesk administrator admin sysadmin],
-
"Content" => %w[content writer editor copywriter]
-
}
-
-
department_keywords.each do |dept_name, keywords|
-
if keywords.any? { |kw| title_lower.include?(kw) }
-
return Category.find_by(name: dept_name, kind: :job_role)
-
end
-
end
-
-
nil
-
end
-
-
def normalize_company_name(name)
-
return nil if name.blank?
-
-
normalized = name.strip
-
suffixes = [
-
/\s+inc\.?$/i,
-
/\s+llc\.?$/i,
-
/\s+corp\.?$/i,
-
/\s+corporation$/i,
-
/\s+ltd\.?$/i,
-
/\s+limited$/i,
-
/\s+co\.?$/i,
-
/\s+company$/i,
-
/\s+\.io$/i,
-
/\s+\.com$/i,
-
/\s+\.net$/i,
-
/\s+\.org$/i
-
]
-
suffixes.each { |suffix| normalized = normalized.gsub(suffix, "") }
-
-
normalized.strip.titleize
-
end
-
-
def normalize_job_role_title(title)
-
return nil if title.blank?
-
title.strip
-
end
-
-
def names_similar?(name1, name2)
-
return false if name1.blank? || name2.blank?
-
return true if name1.downcase == name2.downcase
-
-
n1 = name1.downcase
-
n2 = name2.downcase
-
return true if n1.include?(n2) || n2.include?(n1)
-
-
# Also compare without spaces (handles "Ever Ai" vs "EverAI")
-
n1_compact = n1.gsub(/\s+/, "")
-
n2_compact = n2.gsub(/\s+/, "")
-
return true if n1_compact == n2_compact
-
return true if n1_compact.include?(n2_compact) || n2_compact.include?(n1_compact)
-
-
distance = levenshtein_distance(n1, n2)
-
max_distance = [ name1.length, name2.length ].min / 3
-
distance <= [ max_distance, 2 ].max
-
end
-
-
def levenshtein_distance(str1, str2)
-
m, n = str1.length, str2.length
-
return n if m == 0
-
return m if n == 0
-
-
d = Array.new(m + 1) { Array.new(n + 1) }
-
(0..m).each { |i| d[i][0] = i }
-
(0..n).each { |j| d[0][j] = j }
-
-
(1..n).each do |j|
-
(1..m).each do |i|
-
d[i][j] = if str1[i - 1] == str2[j - 1]
-
d[i - 1][j - 1]
-
else
-
[ d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + 1 ].min
-
end
-
end
-
end
-
-
d[m][n]
-
end
-
-
def extract_domain_from_url(url)
-
return nil if url.blank?
-
-
uri = URI.parse(url)
-
domain = uri.host
-
return nil unless domain
-
-
domain = domain.downcase
-
domain.sub(/^www\./, "")
-
rescue
-
nil
-
end
-
-
def normalize_domain(domain)
-
return "" if domain.blank?
-
domain = domain.gsub(/^https?:\/\//, "")
-
domain = domain.split("/").first
-
domain = domain.downcase
-
domain.sub(/^www\./, "")
-
end
-
-
def domains_match?(domain1, domain2)
-
return false if domain1.blank? || domain2.blank?
-
-
norm1 = normalize_domain(domain1)
-
norm2 = normalize_domain(domain2)
-
return true if norm1 == norm2
-
-
return true if norm1.end_with?(".#{norm2}") || norm2.end_with?(".#{norm1}")
-
-
parts1 = norm1.split(".")
-
parts2 = norm2.split(".")
-
if parts1.length >= 2 && parts2.length >= 2
-
base1 = parts1[-2..-1].join(".")
-
base2 = parts2[-2..-1].join(".")
-
return true if base1 == base2
-
end
-
-
false
-
end
-
-
def find_company_by_domain(domain)
-
return nil if domain.blank?
-
normalized = normalize_domain(domain)
-
-
Company.where.not(website: nil).find_each do |company|
-
company_domain = extract_domain_from_url(company.website)
-
return company if company_domain.present? && domains_match?(normalized, company_domain)
-
end
-
-
nil
-
end
-
-
def find_similar_company(normalized_name)
-
return nil if normalized_name.blank?
-
-
Company.find_each do |company|
-
existing_normalized = normalize_company_name(company.name)
-
return company if names_similar?(normalized_name, existing_normalized)
-
end
-
-
nil
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module Orchestration
-
module Support
-
module JobListingUpdater
-
module_function
-
-
def update_preliminary!(context, preliminary_data)
-
job_listing = context.job_listing
-
updates = {}
-
-
updates[:title] = preliminary_data[:title] if preliminary_data[:title].present? && job_listing.title.blank?
-
updates[:location] = preliminary_data[:location] if preliminary_data[:location].present? && job_listing.location.blank?
-
updates[:remote_type] = preliminary_data[:remote_type] if preliminary_data[:remote_type].present? && job_listing.remote_type == "on_site"
-
updates[:salary_min] = preliminary_data[:salary_min] if preliminary_data[:salary_min].present? && job_listing.salary_min.blank?
-
updates[:salary_max] = preliminary_data[:salary_max] if preliminary_data[:salary_max].present? && job_listing.salary_max.blank?
-
updates[:salary_currency] = preliminary_data[:salary_currency] if preliminary_data[:salary_currency].present?
-
updates[:description] = preliminary_data[:description] if preliminary_data[:description].present? && job_listing.description.blank?
-
updates[:about_company] = preliminary_data[:about_company] if preliminary_data[:about_company].present? && job_listing.about_company.blank?
-
updates[:company_culture] = preliminary_data[:company_culture] if preliminary_data[:company_culture].present? && job_listing.company_culture.blank?
-
-
if preliminary_data[:company_name].present?
-
company = EntityResolver.find_or_create_company(context, preliminary_data[:company_name])
-
updates[:company] = company if job_listing.company_id.nil? || company.id != job_listing.company_id
-
end
-
-
job_role_title = preliminary_data[:job_role_title] || preliminary_data[:title]
-
if job_role_title.present?
-
department_name = preliminary_data[:job_role_department]
-
job_role = EntityResolver.find_or_create_job_role(context, job_role_title, department_name: department_name)
-
updates[:job_role] = job_role if job_listing.job_role_id.nil? || job_role.id != job_listing.job_role_id
-
end
-
-
return false if updates.empty?
-
-
job_listing.update(updates)
-
end
-
-
# Placeholder values that should always be replaced with real extracted data
-
PLACEHOLDER_COMPANY_NAMES = [ "Unknown Company", "Unknown" ].freeze
-
PLACEHOLDER_JOB_ROLES = [ "Unknown Position", "Unknown Role", "Unknown" ].freeze
-
-
def update_final!(context, result)
-
job_listing = context.job_listing
-
-
# Merge custom_sections to preserve existing data while adding new fields
-
merged_custom_sections = (job_listing.custom_sections || {}).merge(result[:custom_sections] || {})
-
-
updates = {
-
title: result[:title] || job_listing.title,
-
description: result[:description] || job_listing.description,
-
about_company: result[:about_company] || job_listing.about_company,
-
company_culture: result[:company_culture] || job_listing.company_culture,
-
requirements: result[:requirements] || job_listing.requirements,
-
responsibilities: result[:responsibilities] || job_listing.responsibilities,
-
salary_min: result[:salary_min] || job_listing.salary_min,
-
salary_max: result[:salary_max] || job_listing.salary_max,
-
salary_currency: result[:salary_currency] || job_listing.salary_currency,
-
equity_info: result[:equity_info] || job_listing.equity_info,
-
benefits: result[:benefits] || job_listing.benefits,
-
perks: result[:perks] || job_listing.perks,
-
location: result[:location] || job_listing.location,
-
remote_type: result[:remote_type] || job_listing.remote_type,
-
custom_sections: merged_custom_sections,
-
scraped_data: build_scraped_metadata(context, result)
-
}
-
-
# Update company if we have extracted data and current is nil/placeholder
-
company_name = result[:company] || result[:company_name]
-
if company_name.present?
-
company = EntityResolver.find_or_create_company(context, company_name)
-
should_update_company = job_listing.company_id.nil? ||
-
company.id != job_listing.company_id ||
-
is_placeholder_company?(job_listing.company)
-
updates[:company] = company if should_update_company
-
end
-
-
# Update job role using title as fallback, replacing placeholders
-
job_role_title = result[:job_role] || result[:title]
-
if job_role_title.present?
-
department_name = result[:job_role_department]
-
job_role = EntityResolver.find_or_create_job_role(context, job_role_title, department_name: department_name)
-
should_update_role = job_listing.job_role_id.nil? ||
-
job_role.id != job_listing.job_role_id ||
-
is_placeholder_job_role?(job_listing.job_role)
-
updates[:job_role] = job_role if should_update_role
-
end
-
-
job_listing.update(updates)
-
end
-
-
def is_placeholder_company?(company)
-
return true if company.nil?
-
PLACEHOLDER_COMPANY_NAMES.any? { |placeholder| company.name&.downcase&.include?(placeholder.downcase) }
-
end
-
-
def is_placeholder_job_role?(job_role)
-
return true if job_role.nil?
-
PLACEHOLDER_JOB_ROLES.any? { |placeholder| job_role.title&.downcase&.include?(placeholder.downcase) }
-
end
-
-
def build_scraped_metadata(context, result)
-
{
-
status: "completed",
-
extraction_method: result[:extraction_method] || "ai",
-
provider: result[:provider],
-
model: result[:model],
-
confidence_score: result[:confidence],
-
tokens_used: result[:tokens_used],
-
extracted_at: Time.current.iso8601,
-
duration_seconds: Time.current - context.started_at
-
}
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
module Orchestration
-
module Support
-
module Observability
-
module_function
-
-
JS_HEAVY_TEXT_THRESHOLD = 1500
-
-
# Returns a diagnosis for whether a page appears JS-heavy, along with the signals used.
-
#
-
# @param html_content [String, nil]
-
# @param cleaned_html [String, nil]
-
# @return [Hash]
-
def js_heavy_diagnosis(html_content:, cleaned_html:)
-
text_len = cleaned_html.to_s.length
-
html = html_content.to_s
-
-
spa_markers = [
-
"__NEXT_DATA__",
-
"data-reactroot",
-
"id=\"app\"",
-
"id=\"root\""
-
]
-
-
found_markers = spa_markers.select { |m| html.include?(m) }
-
-
js_heavy =
-
if text_len >= JS_HEAVY_TEXT_THRESHOLD
-
false
-
else
-
found_markers.any? || text_len < 200
-
end
-
-
reason =
-
if text_len >= JS_HEAVY_TEXT_THRESHOLD
-
"text_above_threshold"
-
elsif found_markers.any?
-
"spa_marker_detected"
-
elsif text_len < 200
-
"very_low_text"
-
else
-
"below_threshold"
-
end
-
-
{
-
js_heavy: js_heavy,
-
reason: reason,
-
text_length: text_len,
-
threshold: JS_HEAVY_TEXT_THRESHOLD,
-
html_size: html.bytesize,
-
spa_markers_found: found_markers
-
}
-
rescue => e
-
{
-
js_heavy: false,
-
reason: "diagnosis_error",
-
error: e.message
-
}
-
end
-
-
def js_heavy_page?(html_content:, cleaned_html:)
-
js_heavy_diagnosis(html_content: html_content, cleaned_html: cleaned_html)[:js_heavy]
-
end
-
-
def create_selectors_html_log(context, selectors_result, fetch_mode:, board_type:, html_size:, cleaned_html_size:)
-
HtmlScrapingLog.create!(
-
scraping_attempt: context.attempt,
-
job_listing: context.job_listing,
-
url: context.job_listing.url,
-
domain: AttemptLifecycle.extract_domain(context.job_listing.url),
-
html_size: html_size,
-
cleaned_html_size: cleaned_html_size,
-
duration_ms: nil,
-
field_results: build_field_results_from_selectors(selectors_result),
-
selectors_tried: selectors_result[:selectors_tried] || {},
-
fetch_mode: fetch_mode,
-
board_type: board_type.to_s,
-
extractor_kind: selectors_result[:extractor_kind] || "job_board_selectors",
-
run_context: "orchestrator"
-
)
-
rescue => e
-
Rails.logger.warn("Failed to create HtmlScrapingLog for selectors extraction: #{e.message}")
-
nil
-
end
-
-
def build_field_results_from_selectors(selectors_result)
-
data = selectors_result[:data] || {}
-
(HtmlScrapingLog::TRACKED_FIELDS + [ "job_role_title" ]).uniq.each_with_object({}) do |field, hash|
-
key = field.to_s
-
value = data[key.to_sym] || data[key]
-
hash[key] = {
-
"success" => value.present?,
-
"value" => value.to_s.truncate(500),
-
"selectors_tried" => Array(selectors_result.dig(:selectors_tried, key))
-
}
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
# Backwards-compatible entrypoint for job listing extraction.
-
#
-
# The actual pipeline lives under Scraping::Orchestration.
-
class OrchestratorService
-
attr_reader :job_listing, :attempt, :event_recorder
-
-
def initialize(job_listing)
-
@job_listing = job_listing
-
@attempt = nil
-
@event_recorder = nil
-
end
-
-
# @return [Boolean] true if extraction completed successfully
-
def call
-
return false unless job_listing.url.present?
-
-
job_listing.save! if job_listing.new_record?
-
-
@attempt = Scraping::Orchestration::Support::AttemptLifecycle.create_attempt!(job_listing)
-
-
# If create_attempt! returns nil, a recent attempt just completed - no need to re-extract
-
if @attempt.nil?
-
Rails.logger.info({
-
event: "extraction_skipped_recent_completion",
-
job_listing_id: job_listing.id
-
}.to_json)
-
return true
-
end
-
-
@event_recorder = Scraping::EventRecorderService.new(@attempt, job_listing: job_listing)
-
-
context = Scraping::Orchestration::Context.new(
-
job_listing: job_listing,
-
attempt: @attempt,
-
event_recorder: @event_recorder
-
)
-
-
Scraping::Orchestration::Support::AttemptLifecycle.log_event(context, "extraction_started")
-
-
Scraping::Orchestration::Runner.new(context).call
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
# Service for rate limiting requests per domain
-
#
-
# Uses Rails.cache to track request timestamps and enforce rate limits
-
# to avoid overwhelming job board servers.
-
#
-
# @example
-
# limiter = Scraping::RateLimiterService.new("linkedin.com")
-
# if limiter.allowed?
-
# # Make request
-
# limiter.record_request!
-
# else
-
# sleep limiter.wait_time
-
# end
-
class RateLimiterService
-
# Initialize the rate limiter for a domain
-
#
-
# @param [String] domain The domain to rate limit
-
def initialize(domain)
-
@domain = domain
-
@cache_key = "rate_limit:#{domain}"
-
@cache = if Rails.cache.is_a?(ActiveSupport::Cache::NullStore)
-
ActiveSupport::Cache::MemoryStore.new
-
else
-
Rails.cache
-
end
-
end
-
-
# Checks if a request to this domain is allowed
-
#
-
# @return [Boolean] True if request can be made now
-
def allowed?
-
last_request_time = cache.read(@cache_key)
-
return true if last_request_time.nil?
-
-
time_since_last = Time.current - last_request_time
-
time_since_last >= rate_limit_seconds
-
end
-
-
# Records a request timestamp for this domain
-
#
-
# @return [Boolean] True if successfully recorded
-
def record_request!
-
cache.write(@cache_key, Time.current, expires_in: 1.hour)
-
end
-
-
# Returns the wait time before next request is allowed
-
#
-
# @return [Float] Seconds to wait, 0 if can request now
-
def wait_time
-
return 0.0 if allowed?
-
-
last_request_time = cache.read(@cache_key)
-
return 0.0 if last_request_time.nil?
-
-
time_since_last = Time.current - last_request_time
-
remaining = rate_limit_seconds - time_since_last
-
[ remaining, 0.0 ].max
-
end
-
-
# Blocks until the domain is ready for another request
-
#
-
# @return [void]
-
def wait_if_needed!
-
wait_seconds = wait_time
-
sleep(wait_seconds) if wait_seconds > 0
-
end
-
-
private
-
-
def cache
-
@cache
-
end
-
-
# Returns the rate limit in seconds for this domain
-
#
-
# @return [Integer] Seconds between requests
-
def rate_limit_seconds
-
@rate_limit_seconds ||= load_rate_limit_config
-
end
-
-
# Loads rate limit from configuration
-
#
-
# @return [Integer] Seconds between requests
-
def load_rate_limit_config
-
config = YAML.load_file(Rails.root.join("config/rate_limits.yml"))
-
-
# Try exact domain match first
-
domain_limits = config["domains"] || {}
-
return domain_limits[@domain] if domain_limits.key?(@domain)
-
-
# Return default
-
config["default"] || 5
-
rescue => e
-
Rails.logger.error("Failed to load rate limit config: #{e.message}")
-
5 # Safe default
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
require "selenium-webdriver"
-
require "timeout"
-
-
module Scraping
-
# Service for fetching JS-rendered HTML using a headless browser (Selenium)
-
#
-
# Intended as a fallback when static HTTP fetch returns a shell page and the
-
# job content is populated client-side via JavaScript.
-
#
-
# Notes:
-
# - This service is expensive; it should be used selectively (heuristics + caching).
-
# - It returns full page HTML and also provides cleaned_html using Nokogiri cleaner.
-
# - Wrapped with Ruby Timeout to prevent indefinite hangs from Selenium.
-
class RenderedHtmlFetcherService < ApplicationService
-
DEFAULT_TIMEOUT_SECONDS = 30
-
DEFAULT_WAIT_SECONDS = 10
-
# Overall timeout for the entire operation (page load + wait + processing)
-
# This is a hard limit to prevent indefinite hangs
-
HARD_TIMEOUT_SECONDS = 90
-
MAX_HTML_BYTES = 5.megabytes
-
MAX_IFRAMES_TO_CHECK = 5
-
-
# A realistic UA (some job boards degrade bot UAs).
-
REALISTIC_UA =
-
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " \
-
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
-
-
# Best-effort selectors for “job content is present”.
-
JOB_CONTENT_SELECTORS = [
-
"[data-testid*='job']",
-
"[data-testid*='description']",
-
"[data-testid*='posting']",
-
"[class*='job-description']",
-
"[class*='jobDescription']",
-
"[class*='job-details']",
-
"[class*='jobDetails']",
-
"[id*='job-description']",
-
"[id*='jobDescription']",
-
"main"
-
].freeze
-
-
attr_reader :job_listing, :scraping_attempt, :url
-
-
# @param job_listing [JobListing]
-
# @param scraping_attempt [ScrapingAttempt, nil]
-
# @param timeout [Integer] Overall page-load timeout
-
# @param wait [Integer] Extra wait for content to settle
-
def initialize(job_listing, scraping_attempt: nil, timeout: DEFAULT_TIMEOUT_SECONDS, wait: DEFAULT_WAIT_SECONDS)
-
@job_listing = job_listing
-
@scraping_attempt = scraping_attempt
-
@url = job_listing.url
-
@timeout = timeout
-
@wait = wait
-
end
-
-
# Fetches rendered HTML using Selenium headless Chrome
-
#
-
# Wrapped with a hard timeout to prevent indefinite hangs from Selenium/network issues.
-
#
-
# @return [Hash] Result with :success, :html_content, :cleaned_html, :http_status, :error, :cached_data
-
def call
-
return error_result("URL is required") if url.blank?
-
return error_result("JS rendering disabled") unless Setting.js_rendering_enabled?
-
-
# Wrap entire operation in a timeout to prevent indefinite hangs
-
Timeout.timeout(HARD_TIMEOUT_SECONDS, RenderedFetchTimeoutError) do
-
perform_fetch
-
end
-
rescue RenderedFetchTimeoutError => e
-
Rails.logger.error("Rendered fetch hard timeout after #{HARD_TIMEOUT_SECONDS}s for #{url}")
-
notify_error(
-
e,
-
context: "rendered_html_fetch_timeout",
-
severity: "warning",
-
url: url,
-
job_listing_id: job_listing.id,
-
scraping_attempt_id: scraping_attempt&.id,
-
timeout_seconds: HARD_TIMEOUT_SECONDS
-
)
-
error_result("Rendered fetch timed out after #{HARD_TIMEOUT_SECONDS} seconds")
-
rescue Selenium::WebDriver::Error::WebDriverError => e
-
notify_error(
-
e,
-
context: "rendered_html_fetch",
-
severity: "error",
-
url: url,
-
job_listing_id: job_listing.id,
-
scraping_attempt_id: scraping_attempt&.id
-
)
-
error_result("Rendered fetch failed: #{e.message}")
-
rescue StandardError => e
-
notify_error(
-
e,
-
context: "rendered_html_fetch",
-
severity: "error",
-
url: url,
-
job_listing_id: job_listing.id,
-
scraping_attempt_id: scraping_attempt&.id
-
)
-
error_result("Rendered fetch failed: #{e.message}")
-
end
-
-
# Custom error for hard timeout
-
class RenderedFetchTimeoutError < StandardError; end
-
-
private
-
-
# Performs the actual fetch operation (separated for timeout wrapping)
-
def perform_fetch
-
driver = nil
-
begin
-
driver = build_driver
-
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
-
-
driver.navigate.to(url)
-
-
# Wait for document readiness
-
wait_until(driver, @timeout) { driver.execute_script("return document.readyState") == "complete" }
-
-
selector_probe = wait_for_job_content(driver)
-
-
# Best-effort settle time for SPAs (bounded)
-
sleep(@wait) if @wait.positive?
-
-
html = driver.page_source.to_s
-
if html.bytesize > MAX_HTML_BYTES
-
return error_result("Rendered HTML too large (#{html.bytesize} bytes)")
-
end
-
-
# Use board-specific cleaner if available
-
cleaner = Scraping::HtmlCleaners::CleanerFactory.cleaner_for_url(url)
-
cleaned_html = cleaner.clean(html)
-
-
iframe_result = selector_probe[:iframe_best_candidate]
-
if iframe_result.present?
-
# Prefer iframe HTML if it yields more extracted text
-
iframe_cleaned = cleaner.clean(iframe_result[:iframe_html].to_s)
-
if extracted_text_length(iframe_cleaned) > extracted_text_length(cleaned_html)
-
html = iframe_result[:iframe_html].to_s
-
cleaned_html = iframe_cleaned
-
selector_probe[:iframe_used] = true
-
end
-
end
-
-
cleaned_text_length = extracted_text_length(cleaned_html)
-
-
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
-
-
cached_data = ScrapedJobListingData.create_with_html(
-
url: url,
-
html_content: html,
-
job_listing: job_listing,
-
scraping_attempt: scraping_attempt,
-
http_status: nil,
-
metadata: {
-
fetched_via: "selenium",
-
rendered: true,
-
duration_ms: duration_ms,
-
selector_found: selector_probe[:selector_found],
-
found_selectors: selector_probe[:found_selectors],
-
selector_wait_ms: selector_probe[:selector_wait_ms],
-
iframe_used: selector_probe[:iframe_used],
-
cleaned_text_length: cleaned_text_length
-
}
-
)
-
-
{
-
success: true,
-
html_content: html,
-
cleaned_html: cleaned_html,
-
cached_data: cached_data,
-
from_cache: false,
-
http_status: nil,
-
duration_ms: duration_ms,
-
selector_found: selector_probe[:selector_found],
-
found_selectors: selector_probe[:found_selectors],
-
selector_wait_ms: selector_probe[:selector_wait_ms],
-
iframe_used: selector_probe[:iframe_used],
-
cleaned_text_length: cleaned_text_length
-
}
-
ensure
-
driver&.quit
-
end
-
end
-
-
def build_driver
-
options = Selenium::WebDriver::Chrome::Options.new
-
options.add_argument("--headless=new")
-
options.add_argument("--no-sandbox")
-
options.add_argument("--disable-dev-shm-usage")
-
options.add_argument("--disable-gpu")
-
options.add_argument("--window-size=1280,1024")
-
options.add_argument("--lang=en-US")
-
-
# Prefer a realistic UA to avoid degraded “bot” experiences.
-
options.add_argument("--user-agent=#{REALISTIC_UA}")
-
-
# Support remote Selenium Grid via environment variables (for scaling)
-
if selenium_remote_url.present?
-
driver = Selenium::WebDriver.for(:remote, url: selenium_remote_url, options: options)
-
else
-
# Local ChromeDriver (will auto-download via webdriver gem if needed)
-
driver = Selenium::WebDriver.for(:chrome, options: options)
-
end
-
-
driver.manage.timeouts.page_load = @timeout
-
driver
-
end
-
-
# Returns remote Selenium Grid URL if configured, nil otherwise
-
#
-
# @return [String, nil]
-
def selenium_remote_url
-
return nil unless ENV["SELENIUM_REMOTE_URL"].present?
-
-
ENV["SELENIUM_REMOTE_URL"]
-
end
-
-
def wait_until(driver, seconds)
-
wait = Selenium::WebDriver::Wait.new(timeout: seconds)
-
wait.until { yield }
-
rescue Selenium::WebDriver::Error::TimeoutError
-
# Continue best-effort if readiness never reports complete
-
nil
-
end
-
-
# Waits (best-effort) until job content appears in the DOM.
-
#
-
# @param driver [Selenium::WebDriver]
-
# @return [Hash]
-
def wait_for_job_content(driver)
-
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
-
-
found_selectors = []
-
selector_found = false
-
iframe_best_candidate = nil
-
iframe_used = false
-
-
selector_found, found_selectors = wait_for_any_selector(driver, JOB_CONTENT_SELECTORS, timeout: [ @timeout, 15 ].min)
-
-
# If not found, check a small number of iframes for content (best-effort).
-
if !selector_found
-
iframes = driver.find_elements(css: "iframe").first(MAX_IFRAMES_TO_CHECK)
-
iframes.each do |iframe|
-
begin
-
driver.switch_to.frame(iframe)
-
iframe_found, iframe_selectors = wait_for_any_selector(driver, JOB_CONTENT_SELECTORS, timeout: 6)
-
if iframe_found
-
iframe_html = driver.page_source.to_s
-
iframe_best_candidate = { iframe_html: iframe_html, found_selectors: iframe_selectors }
-
break
-
end
-
rescue Selenium::WebDriver::Error::WebDriverError
-
nil
-
ensure
-
begin
-
driver.switch_to.default_content
-
rescue Selenium::WebDriver::Error::WebDriverError
-
nil
-
end
-
end
-
end
-
end
-
-
selector_wait_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
-
-
{
-
selector_found: selector_found,
-
found_selectors: found_selectors.first(10),
-
selector_wait_ms: selector_wait_ms,
-
iframe_used: iframe_used,
-
iframe_best_candidate: iframe_best_candidate
-
}
-
rescue => e
-
Rails.logger.debug("RenderedHtmlFetcherService wait_for_job_content error: #{e.message}")
-
{ selector_found: false, found_selectors: [], selector_wait_ms: nil, iframe_used: false, iframe_best_candidate: nil }
-
end
-
-
def wait_for_any_selector(driver, selectors, timeout:)
-
found = []
-
wait_until(driver, timeout) do
-
found = selectors.select { |sel| dom_has_selector?(driver, sel) }
-
found.any?
-
end
-
[ found.any?, found ]
-
rescue
-
[ false, [] ]
-
end
-
-
def dom_has_selector?(driver, selector)
-
driver.execute_script("return !!document.querySelector(arguments[0])", selector) == true
-
rescue Selenium::WebDriver::Error::JavascriptError
-
false
-
end
-
-
# Rough “how much text do we have?” metric for deciding if rendered fetch worked.
-
#
-
# @param html [String]
-
# @return [Integer]
-
def extracted_text_length(html)
-
Nokogiri::HTML(html.to_s).text.to_s.strip.length
-
rescue
-
0
-
end
-
-
def error_result(message)
-
{
-
success: false,
-
error: message,
-
html_content: nil,
-
cleaned_html: nil,
-
cached_data: nil,
-
from_cache: false
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
# Service for retrying failed scraping attempts
-
#
-
# Provides idempotent retry logic for individual steps, leveraging
-
# cached HTML when available to avoid re-fetching.
-
#
-
# @example
-
# retry_service = Scraping::RetryService.new(scraping_attempt)
-
# result = retry_service.retry_html_fetch
-
# result = retry_service.retry_extraction
-
# result = retry_service.retry_full
-
class RetryService
-
attr_reader :scraping_attempt, :job_listing
-
-
# Initialize the retry service
-
#
-
# @param [ScrapingAttempt] scraping_attempt The failed scraping attempt
-
def initialize(scraping_attempt)
-
@scraping_attempt = scraping_attempt
-
@job_listing = scraping_attempt.job_listing
-
end
-
-
# Retries HTML fetching step
-
#
-
# @return [Hash] Result hash with success status
-
def retry_html_fetch
-
return error_result("Attempt is not in a retryable state") unless can_retry_html_fetch?
-
-
@scraping_attempt.retry_attempt! if @scraping_attempt.failed?
-
@scraping_attempt.start_fetch!
-
-
fetcher = HtmlFetcherService.new(@job_listing, scraping_attempt: @scraping_attempt)
-
result = fetcher.call
-
-
if result[:success]
-
# Link cached data to this attempt
-
result[:cached_data]&.update(scraping_attempt: @scraping_attempt) if result[:cached_data]
-
success_result("HTML fetch succeeded", result)
-
else
-
@scraping_attempt.update(failed_step: "html_fetch", error_message: result[:error])
-
@scraping_attempt.mark_failed!
-
error_result(result[:error] || "HTML fetch failed")
-
end
-
rescue => e
-
@scraping_attempt.update(failed_step: "html_fetch", error_message: e.message)
-
@scraping_attempt.mark_failed!
-
error_result(e.message)
-
end
-
-
# Retries extraction step (AI or API) using cached HTML
-
#
-
# @return [Hash] Result hash with success status
-
def retry_extraction
-
return error_result("Attempt is not in a retryable state") unless can_retry_extraction?
-
-
# Get cached HTML if available
-
cached_data = @scraping_attempt.cached_html_data
-
unless cached_data
-
return error_result("No cached HTML available for retry")
-
end
-
-
@scraping_attempt.retry_attempt! if @scraping_attempt.failed?
-
@scraping_attempt.start_extract!
-
-
# Try API extraction first if applicable
-
detector = Scraping::JobBoardDetectorService.new(@job_listing.url)
-
if Setting.api_population_enabled? && detector.api_supported? && detector.company_slug.present?
-
api_result = try_api_extraction(detector.detect, detector.company_slug, detector.job_id)
-
if api_result && api_result[:confidence] && api_result[:confidence] >= 0.7
-
update_job_listing(api_result)
-
complete_attempt(api_result)
-
return success_result("API extraction succeeded", api_result)
-
end
-
end
-
-
# Try AI extraction with cached HTML
-
ai_result = try_ai_extraction_with_cache(cached_data)
-
if ai_result && ai_result[:confidence] && ai_result[:confidence] >= 0.7
-
update_job_listing(ai_result)
-
complete_attempt(ai_result)
-
success_result("AI extraction succeeded", ai_result)
-
else
-
@scraping_attempt.update(failed_step: "ai_extraction", error_message: "Low confidence: #{ai_result[:confidence] || 0.0}")
-
@scraping_attempt.mark_failed!
-
error_result("Extraction failed: Low confidence")
-
end
-
rescue => e
-
@scraping_attempt.update(failed_step: "ai_extraction", error_message: e.message)
-
@scraping_attempt.mark_failed!
-
error_result(e.message)
-
end
-
-
# Retries the entire process from scratch
-
#
-
# @return [Hash] Result hash with success status
-
def retry_full
-
orchestrator = OrchestratorService.new(@job_listing)
-
success = orchestrator.call
-
-
if success
-
success_result("Full retry succeeded")
-
else
-
error_result("Full retry failed")
-
end
-
end
-
-
private
-
-
# Checks if HTML fetch can be retried
-
#
-
# @return [Boolean] True if can retry
-
def can_retry_html_fetch?
-
@scraping_attempt.failed? || @scraping_attempt.retrying?
-
end
-
-
# Checks if extraction can be retried
-
#
-
# @return [Boolean] True if can retry
-
def can_retry_extraction?
-
(@scraping_attempt.failed? || @scraping_attempt.retrying?) &&
-
(@scraping_attempt.ai_extraction_failed? || @scraping_attempt.api_extraction_failed?)
-
end
-
-
# Tries API extraction
-
#
-
# @param [Symbol] board_type The board type
-
# @param [String] company_slug Company identifier
-
# @param [String] job_id Job identifier
-
# @return [Hash, nil] Extracted data or nil
-
def try_api_extraction(board_type, company_slug, job_id)
-
fetcher = get_api_fetcher(board_type)
-
return nil unless fetcher
-
-
fetcher.fetch(
-
url: @job_listing.url,
-
company_slug: company_slug,
-
job_id: job_id
-
)
-
rescue => e
-
Rails.logger.error("API extraction retry failed: #{e.message}")
-
nil
-
end
-
-
# Gets the appropriate API fetcher for a board type
-
#
-
# @param [Symbol] board_type The board type
-
# @return [ApiFetchers::BaseFetcher, nil] Fetcher instance or nil
-
def get_api_fetcher(board_type)
-
case board_type
-
when :greenhouse
-
ApiFetchers::GreenhouseFetcher.new
-
when :lever
-
ApiFetchers::LeverFetcher.new
-
else
-
nil
-
end
-
end
-
-
# Tries AI extraction with cached HTML
-
#
-
# @param [ScrapedJobListingData] cached_data The cached HTML data
-
# @return [Hash] Extracted data
-
def try_ai_extraction_with_cache(cached_data)
-
extractor = AiJobExtractorService.new(@job_listing, scraping_attempt: @scraping_attempt)
-
extractor.extract(
-
html_content: cached_data.html_content,
-
cleaned_html: cached_data.cleaned_html
-
)
-
rescue => e
-
Rails.logger.error("AI extraction retry failed: #{e.message}")
-
{ error: e.message, confidence: 0.0 }
-
end
-
-
# Updates the job listing with extracted data
-
#
-
# @param [Hash] result The extracted data
-
def update_job_listing(result)
-
@job_listing.update(
-
title: result[:title] || @job_listing.title,
-
description: result[:description] || @job_listing.description,
-
requirements: result[:requirements] || @job_listing.requirements,
-
responsibilities: result[:responsibilities] || @job_listing.responsibilities,
-
salary_min: result[:salary_min] || @job_listing.salary_min,
-
salary_max: result[:salary_max] || @job_listing.salary_max,
-
salary_currency: result[:salary_currency] || @job_listing.salary_currency,
-
equity_info: result[:equity_info] || @job_listing.equity_info,
-
benefits: result[:benefits] || @job_listing.benefits,
-
perks: result[:perks] || @job_listing.perks,
-
location: result[:location] || @job_listing.location,
-
remote_type: result[:remote_type] || @job_listing.remote_type,
-
custom_sections: result[:custom_sections] || @job_listing.custom_sections,
-
scraped_data: build_scraped_metadata(result)
-
)
-
end
-
-
# Builds scraped metadata for storage
-
#
-
# @param [Hash] result The extraction result
-
# @return [Hash] Metadata hash
-
def build_scraped_metadata(result)
-
{
-
status: "completed",
-
extraction_method: result[:extraction_method] || "ai",
-
provider: result[:provider],
-
model: result[:model],
-
confidence_score: result[:confidence],
-
tokens_used: result[:tokens_used],
-
extracted_at: Time.current.iso8601,
-
retried: true
-
}
-
end
-
-
# Completes the attempt successfully
-
#
-
# @param [Hash] result The extraction result
-
def complete_attempt(result)
-
@scraping_attempt.update(
-
extraction_method: result[:extraction_method] || "ai",
-
provider: result[:provider],
-
confidence_score: result[:confidence],
-
duration_seconds: Time.current - @scraping_attempt.created_at,
-
response_metadata: {
-
model: result[:model],
-
tokens_used: result[:tokens_used]
-
}
-
)
-
@scraping_attempt.mark_completed!
-
end
-
-
# Returns a success result hash
-
#
-
# @param [String] message Success message
-
# @param [Hash] data Additional data
-
# @return [Hash] Success result
-
def success_result(message, data = {})
-
{
-
success: true,
-
message: message
-
}.merge(data)
-
end
-
-
# Returns an error result hash
-
#
-
# @param [String] error_message The error message
-
# @return [Hash] Error result
-
def error_result(error_message)
-
{
-
success: false,
-
error: error_message
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Scraping
-
# Service for checking robots.txt compliance
-
#
-
# Fetches and caches robots.txt files, then checks if a URL is allowed
-
# to be scraped according to the robots.txt rules.
-
#
-
# @example
-
# checker = Scraping::RobotsTxtCheckerService.new("https://example.com/jobs/123")
-
# if checker.allowed?
-
# # Proceed with scraping
-
# end
-
class RobotsTxtCheckerService
-
USER_AGENT = "GleaniaBot/1.0"
-
-
# Initialize the robots.txt checker for a URL
-
#
-
# @param [String] url The URL to check
-
def initialize(url)
-
@url = url
-
@uri = URI.parse(url)
-
@domain = @uri.host
-
end
-
-
# Checks if scraping this URL is allowed per robots.txt
-
#
-
# @return [Boolean] True if allowed or if robots.txt doesn't exist
-
def allowed?
-
return true unless @domain # Can't check without domain
-
-
begin
-
# The robots gem automatically fetches robots.txt for the domain
-
parser = Robots.new(USER_AGENT)
-
parser.allowed?(@url)
-
rescue => e
-
Rails.logger.warn("Failed to check robots.txt for #{@domain}: #{e.message}")
-
true # On error, allow by default to not block functionality
-
end
-
end
-
-
# Returns the crawl delay specified in robots.txt
-
#
-
# @return [Integer, nil] Delay in seconds or nil if not specified
-
def crawl_delay
-
robots_txt_content = fetch_robots_txt
-
return nil if robots_txt_content.nil?
-
-
# Parse crawl-delay directive
-
match = robots_txt_content.match(/Crawl-delay:\s*(\d+)/i)
-
match ? match[1].to_i : nil
-
rescue => e
-
Rails.logger.warn("Failed to get crawl delay for #{@domain}: #{e.message}")
-
nil
-
end
-
-
private
-
-
# Fetches robots.txt content for the domain
-
#
-
# @return [String, nil] robots.txt content or nil if not found
-
def fetch_robots_txt
-
cache_key = "robots_txt:#{@domain}"
-
-
Rails.cache.fetch(cache_key, expires_in: 24.hours) do
-
fetch_robots_txt_from_server
-
end
-
end
-
-
# Fetches robots.txt from the server
-
#
-
# @return [String, nil] robots.txt content or nil
-
def fetch_robots_txt_from_server
-
robots_url = "#{@uri.scheme}://#{@domain}/robots.txt"
-
-
response = HTTParty.get(
-
robots_url,
-
headers: {
-
"User-Agent" => USER_AGENT
-
},
-
timeout: 10,
-
follow_redirects: true
-
)
-
-
if response.success?
-
Rails.logger.info("Fetched robots.txt for #{@domain}")
-
response.body
-
else
-
Rails.logger.info("No robots.txt found for #{@domain} (#{response.code})")
-
nil
-
end
-
rescue => e
-
Rails.logger.warn("Failed to fetch robots.txt for #{@domain}: #{e.message}")
-
nil
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Scraping
-
# Validates and normalizes salary ranges extracted from job listings.
-
#
-
# This is intentionally conservative: it's better to show no salary range than
-
# to show one that is likely a false-positive (e.g., "89 - 7 USD" coming from
-
# unrelated numbers in the page).
-
#
-
# @example
-
# res = Scraping::SalaryRangeValidator.normalize(min: 120_000, max: 150_000, currency: "USD", context_text: "per year")
-
# res[:valid] # => true
-
#
-
class SalaryRangeValidator
-
MIN_ANNUAL_SALARY = 10_000
-
MAX_ANNUAL_SALARY = 2_000_000
-
CURRENCY_RE = /\A[A-Z]{3}\z/
-
-
# Normalizes and validates a salary range.
-
#
-
# @param min [Numeric, String, nil]
-
# @param max [Numeric, String, nil]
-
# @param currency [String, nil]
-
# @param context_text [String, nil] Nearby text to infer units (year/month/hour)
-
# @return [Hash] { valid:, min:, max:, currency:, reason: }
-
def self.normalize(min:, max:, currency:, context_text: nil)
-
min_n = coerce_number(min)
-
max_n = coerce_number(max)
-
cur = currency.to_s.strip.upcase.presence
-
-
return invalid("missing_salary") if min_n.nil? && max_n.nil?
-
return invalid("missing_currency") if cur.blank? || cur !~ CURRENCY_RE
-
return invalid("inverted_range") if min_n && max_n && max_n < min_n
-
-
unit = infer_unit(context_text.to_s)
-
return invalid("non_annual_unit") if unit && unit != :year
-
-
return invalid("min_out_of_bounds") if min_n && !annual_amount_plausible?(min_n)
-
return invalid("max_out_of_bounds") if max_n && !annual_amount_plausible?(max_n)
-
-
{
-
valid: true,
-
min: min_n,
-
max: max_n,
-
currency: cur,
-
reason: nil
-
}
-
end
-
-
def self.invalid(reason)
-
{ valid: false, min: nil, max: nil, currency: nil, reason: reason }
-
end
-
-
def self.annual_amount_plausible?(amount)
-
amount >= MIN_ANNUAL_SALARY && amount <= MAX_ANNUAL_SALARY
-
end
-
-
def self.infer_unit(text)
-
t = text.to_s.downcase
-
return :hour if t.match?(/\b(per\s*hour|hourly|\/\s*hr|\/\s*h)\b/)
-
return :month if t.match?(/\b(per\s*month|monthly|\/\s*mo|\/\s*month)\b/)
-
return :year if t.match?(/\b(per\s*year|annual|yearly|\/\s*yr|\/\s*year)\b/)
-
-
nil
-
end
-
-
def self.coerce_number(value)
-
return nil if value.nil?
-
return value.to_f if value.is_a?(Numeric)
-
-
str = value.to_s.strip
-
return nil if str.blank?
-
-
# Remove currency symbols and whitespace, keep digits, dots, commas, and "k".
-
cleaned = str.gsub(/[^\d.,kK]/, "")
-
return nil if cleaned.blank?
-
-
multiplier = 1.0
-
if cleaned.match?(/[kK]\z/)
-
multiplier = 1000.0
-
cleaned = cleaned.gsub(/[kK]\z/, "")
-
end
-
-
num = parse_decimalish(cleaned)
-
num ? (num * multiplier) : nil
-
end
-
-
def self.parse_decimalish(str)
-
s = str.to_s
-
return nil if s.blank?
-
-
# If it looks like a decimal-comma (e.g., "89,7"), treat comma as decimal separator.
-
if s.include?(",") && !s.include?(".") && s.match?(/\A\d+,\d{1,2}\z/)
-
s = s.tr(",", ".")
-
else
-
# Otherwise treat commas as thousands separators.
-
s = s.delete(",")
-
end
-
-
Float(s)
-
rescue ArgumentError, TypeError
-
nil
-
end
-
-
private_class_method :annual_amount_plausible?, :infer_unit, :coerce_number, :parse_decimalish, :invalid
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
# Applies planned actions in deterministic order.
-
class ActionApplier < ApplicationService
-
ACTION_ORDER = [
-
:mark_latest_round_failed,
-
:sync_application_from_round_result,
-
:set_application_status,
-
:set_pipeline_stage,
-
:sync_pipeline_from_round_stage
-
].freeze
-
-
def initialize(context)
-
@context = context
-
@application = context.application
-
end
-
-
def apply!(actions)
-
return [] if actions.blank? || @application.blank?
-
-
applied = []
-
ordered = actions.sort_by { |action| ACTION_ORDER.index(action[:type]) || ACTION_ORDER.length }
-
-
@application.reload
-
ordered.each do |action|
-
case action[:type]
-
when :mark_latest_round_failed
-
applied << apply_mark_latest_round_failed
-
when :sync_application_from_round_result
-
applied << apply_application_from_round_result
-
when :set_application_status
-
applied << apply_application_status(action[:status])
-
when :set_pipeline_stage
-
applied << apply_pipeline_stage(action[:stage])
-
when :sync_pipeline_from_round_stage
-
applied << apply_pipeline_from_round_stage
-
end
-
end
-
-
applied.compact
-
rescue StandardError => e
-
notify_error(
-
e,
-
context: "signal_action_applier",
-
user: @context.synced_email&.user,
-
synced_email_id: @context.synced_email&.id,
-
application_id: @application&.id
-
)
-
log_error("Failed to apply actions for email #{@context.synced_email&.id}: #{e.message}")
-
[]
-
end
-
-
private
-
-
def apply_mark_latest_round_failed
-
round = pending_rounds.first || latest_round
-
return nil unless round&.result == "pending"
-
-
round.update!(result: :failed, completed_at: Time.current)
-
{ type: :mark_latest_round_failed, round_id: round.id }
-
end
-
-
def apply_application_from_round_result
-
round = latest_round || pending_rounds.first
-
return nil unless round
-
-
case round.result
-
when "failed"
-
apply_application_status(:rejected)
-
apply_pipeline_stage(:closed)
-
when "passed", "waitlisted"
-
apply_pipeline_stage(:interviewing)
-
end
-
end
-
-
def apply_application_status(status)
-
case status&.to_sym
-
when :rejected
-
return nil unless @application.may_reject?
-
@application.reject!
-
when :accepted
-
return nil unless @application.may_accept?
-
@application.accept!
-
when :archived
-
return nil unless @application.may_archive?
-
@application.archive!
-
when :on_hold
-
return nil unless @application.respond_to?(:may_hold?) && @application.may_hold?
-
@application.hold!
-
when :withdrawn
-
return nil unless @application.respond_to?(:may_withdraw?) && @application.may_withdraw?
-
@application.withdraw!
-
when :active
-
return nil unless @application.may_reactivate?
-
@application.reactivate!
-
end
-
-
{ type: :set_application_status, status: @application.status }
-
end
-
-
def apply_pipeline_stage(stage)
-
event_method = case stage&.to_sym
-
when :screening then :move_to_screening
-
when :interviewing then :move_to_interviewing
-
when :offer then :move_to_offer
-
when :closed then :move_to_closed
-
when :applied then :move_to_applied
-
end
-
-
return nil unless event_method
-
return nil unless @application.aasm(:pipeline_stage).may_fire_event?(event_method)
-
-
@application.send("#{event_method}!")
-
{ type: :set_pipeline_stage, stage: @application.pipeline_stage }
-
end
-
-
def apply_pipeline_from_round_stage
-
round = latest_round
-
return nil unless round
-
-
target_stage = round.stage == "screening" ? :screening : :interviewing
-
apply_pipeline_stage(target_stage)
-
end
-
-
def latest_round
-
@application.interview_rounds.ordered.last
-
end
-
-
def pending_rounds
-
@application.interview_rounds.where(result: :pending).order(scheduled_at: :desc)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
# Executes backend signal actions derived from email content
-
#
-
# Dispatches to specific action handlers based on action type.
-
# Only handles actions that require backend processing (not simple URL opens).
-
#
-
# @example
-
# executor = Signals::ActionExecutor.new(synced_email, user, "start_application")
-
# result = executor.execute
-
# if result[:success]
-
# # Action completed successfully
-
# end
-
#
-
class ActionExecutor < ApplicationService
-
attr_reader :synced_email, :user, :action_type, :params
-
-
# Valid backend action types that require user decision
-
# Note: URL-based actions are handled directly in the UI via action_links
-
# Note: Recruiter/company saving happens automatically during extraction
-
# Note: match_application is handled via dropdown in detail panel
-
VALID_ACTIONS = %w[
-
start_application
-
].freeze
-
-
# Initialize the executor
-
#
-
# @param synced_email [SyncedEmail] The email with extracted signals
-
# @param user [User] The user executing the action
-
# @param action_type [String] The action to execute
-
# @param params [Hash] Additional parameters for the action
-
def initialize(synced_email, user, action_type, params = {})
-
@synced_email = synced_email
-
@user = user
-
@action_type = action_type.to_s
-
# Handle both ActionController::Parameters and regular Hash
-
@params = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h.with_indifferent_access : params.to_h.with_indifferent_access
-
end
-
-
# Executes the action
-
#
-
# @return [Hash] Result with success status, message, and optional redirect/data
-
def execute
-
return invalid_action_result unless valid_action?
-
-
action_class = action_handler_class
-
return unsupported_action_result unless action_class
-
-
handler = action_class.new(synced_email, user, params)
-
handler.execute
-
rescue StandardError => e
-
Rails.logger.error("Signal action execution failed: #{e.class} - #{e.message}")
-
notify_error(
-
e,
-
context: "signal_action",
-
user: user,
-
action_type: action_type,
-
synced_email_id: synced_email&.id
-
)
-
{ success: false, error: e.message }
-
end
-
-
# Checks if the action type is valid
-
#
-
# @return [Boolean]
-
def valid_action?
-
VALID_ACTIONS.include?(action_type)
-
end
-
-
private
-
-
# Returns the handler class for the action type
-
#
-
# @return [Class, nil]
-
def action_handler_class
-
case action_type
-
when "start_application"
-
Actions::StartApplicationAction
-
end
-
end
-
-
# Result for invalid action type
-
#
-
# @return [Hash]
-
def invalid_action_result
-
{ success: false, error: "Invalid action type: #{action_type}" }
-
end
-
-
# Result for unsupported action type
-
#
-
# @return [Hash]
-
def unsupported_action_result
-
{ success: false, error: "Action not supported: #{action_type}" }
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Actions
-
# Base class for signal actions
-
#
-
# Provides common functionality for all action handlers.
-
# Subclasses must implement the #execute method.
-
#
-
class BaseAction
-
attr_reader :synced_email, :user, :params
-
-
# Initialize the action
-
#
-
# @param synced_email [SyncedEmail] The email with extracted signals
-
# @param user [User] The user executing the action
-
# @param params [Hash] Additional parameters
-
def initialize(synced_email, user, params = {})
-
@synced_email = synced_email
-
@user = user
-
@params = params.with_indifferent_access
-
end
-
-
# Executes the action
-
#
-
# @return [Hash] Result with success status and relevant data
-
def execute
-
raise NotImplementedError, "Subclasses must implement #execute"
-
end
-
-
protected
-
-
# Returns the extracted company name
-
#
-
# @return [String, nil]
-
def company_name
-
synced_email.signal_company_name
-
end
-
-
# Returns the extracted company website
-
#
-
# @return [String, nil]
-
def company_website
-
synced_email.signal_company_website
-
end
-
-
# Returns the extracted careers URL
-
#
-
# @return [String, nil]
-
def careers_url
-
synced_email.signal_company_careers_url
-
end
-
-
# Returns the extracted job title
-
#
-
# @return [String, nil]
-
def job_title
-
synced_email.signal_job_title
-
end
-
-
# Returns the extracted job URL
-
#
-
# @return [String, nil]
-
def job_url
-
synced_email.signal_job_url
-
end
-
-
# Returns the first scheduling link from action_links
-
#
-
# @return [String, nil]
-
def scheduling_link
-
return nil unless synced_email.signal_action_links.is_a?(Array)
-
-
# Find first link with priority 1 (scheduling links) or label containing "schedule"
-
scheduling = synced_email.signal_action_links.find do |link|
-
link["priority"] == 1 || link["action_label"]&.downcase&.include?("schedule")
-
end
-
scheduling&.dig("url")
-
end
-
-
# Returns the extracted recruiter name
-
#
-
# @return [String, nil]
-
def recruiter_name
-
synced_email.signal_recruiter_name
-
end
-
-
# Returns the extracted recruiter email
-
#
-
# @return [String, nil]
-
def recruiter_email
-
synced_email.signal_recruiter_email || synced_email.from_email
-
end
-
-
# Builds a success result
-
#
-
# @param message [String] Success message
-
# @param data [Hash] Additional data
-
# @return [Hash]
-
def success_result(message, data = {})
-
{ success: true, message: message }.merge(data)
-
end
-
-
# Builds a failure result
-
#
-
# @param error [String] Error message
-
# @return [Hash]
-
def failure_result(error)
-
{ success: false, error: error }
-
end
-
-
# Builds a redirect result
-
#
-
# @param url [String] URL to redirect to
-
# @param message [String] Optional message
-
# @return [Hash]
-
def redirect_result(url, message = nil)
-
result = { success: true, redirect_url: url, external: true }
-
result[:message] = message if message
-
result
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Actions
-
# Creates a new interview application from extracted signal data
-
#
-
# Uses the company name, job title, and other extracted information
-
# to create a new InterviewApplication record and optionally a Company.
-
# If a job URL is detected, also creates a JobListing and triggers scraping.
-
#
-
# @example
-
# action = StartApplicationAction.new(synced_email, user, {})
-
# result = action.execute
-
# # => { success: true, application: InterviewApplication, company: Company, job_listing: JobListing }
-
#
-
class StartApplicationAction < BaseAction
-
# Job URL detection patterns for action links
-
JOB_LINK_LABELS = /view.*job|job.*posting|apply|see.*position|full.*description|job.*details/i
-
JOB_URL_PATTERNS = /lever\.co|greenhouse\.io|workday|myworkday|jobs\.|careers\.|ashbyhq\.com|smartrecruiters|jobvite|icims|bamboohr/i
-
-
# Executes the action to create a new application
-
#
-
# @return [Hash] Result with created application and company
-
def execute
-
return failure_result("No company name extracted") unless company_name.present?
-
-
ActiveRecord::Base.transaction do
-
# Find or create the company (global model)
-
company = find_or_create_company
-
-
# Find or create job role
-
job_role = find_or_create_job_role
-
-
# Create job listing if we have a URL
-
job_listing = create_job_listing_if_url_present(company, job_role)
-
-
# Create the application with optional job listing
-
application = create_application(company, job_role, job_listing)
-
-
# Link the email to the application
-
synced_email.match_to_application!(application)
-
-
# Trigger job listing scraping in background if we have a new listing
-
if job_listing.present? && job_listing.extraction_status == "pending"
-
ScrapeJobListingJob.perform_later(job_listing)
-
end
-
-
success_result(
-
"Application started at #{company.name}",
-
application: application,
-
company: company,
-
job_listing: job_listing,
-
redirect_path: Rails.application.routes.url_helpers.interview_application_path(application)
-
)
-
end
-
rescue ActiveRecord::RecordInvalid => e
-
failure_result("Failed to create application: #{e.message}")
-
end
-
-
private
-
-
# Finds or creates a company from extracted data
-
# Note: Company is a global model, not user-scoped
-
#
-
# @return [Company]
-
def find_or_create_company
-
normalized_name = normalize_company_name(company_name)
-
-
# Try to find existing company by name (case-insensitive)
-
existing = Company.find_by("LOWER(name) = ?", normalized_name.downcase)
-
-
if existing
-
# Update website if we have one and it's missing
-
if company_website.present? && existing.website.blank?
-
existing.update(website: company_website)
-
end
-
return existing
-
end
-
-
# Create new company with extracted data
-
Company.create!(
-
name: normalized_name,
-
website: company_website
-
)
-
end
-
-
# Finds or creates a job role from extracted data
-
#
-
# @return [JobRole]
-
def find_or_create_job_role
-
role_title = job_title.presence || "Position via #{recruiter_name || 'Recruiter'}"
-
-
# Try to find existing
-
existing = JobRole.find_by("LOWER(title) = ?", role_title.downcase)
-
return existing if existing
-
-
# Create new job role
-
JobRole.create!(title: role_title)
-
end
-
-
# Detects job URL from extracted signals
-
# Checks signal_job_url first, then looks in action_links for job-related URLs
-
#
-
# @return [String, nil]
-
def detected_job_url
-
# Priority 1: Direct job URL from signal extraction
-
return job_url if job_url.present?
-
-
# Priority 2: Look in action_links for job-related URLs
-
return nil unless synced_email.signal_action_links.is_a?(Array)
-
-
job_link = synced_email.signal_action_links.find do |link|
-
next unless link.is_a?(Hash)
-
-
label = link["action_label"].to_s
-
url = link["url"].to_s
-
-
# Match labels that indicate job postings
-
next true if label.match?(JOB_LINK_LABELS)
-
-
# Match URLs that look like job posting platforms
-
next true if url.match?(JOB_URL_PATTERNS)
-
-
false
-
end
-
-
job_link&.dig("url")
-
end
-
-
# Creates a job listing if we have a URL
-
# Finds existing listing by URL or creates a new one
-
#
-
# @param company [Company] The company
-
# @param job_role [JobRole] The job role
-
# @return [JobListing, nil]
-
def create_job_listing_if_url_present(company, job_role)
-
url = detected_job_url
-
return nil unless url.present?
-
-
# Normalize URL for comparison
-
normalized_url = normalize_job_url(url)
-
-
# Check if job listing already exists for this URL
-
existing = JobListing.find_by(url: normalized_url)
-
return existing if existing
-
-
# Also check without query params for some URLs
-
base_url = normalized_url.split("?").first
-
existing_base = JobListing.find_by(url: base_url) if base_url != normalized_url
-
return existing_base if existing_base
-
-
# Create new job listing (extraction_status defaults to "pending" via scraped_data)
-
JobListing.create!(
-
url: normalized_url,
-
company: company,
-
job_role: job_role,
-
title: job_title.presence || "#{job_role.title} at #{company.name}",
-
status: :active
-
)
-
end
-
-
# Normalizes a job URL for consistent storage
-
#
-
# @param url [String] The raw URL
-
# @return [String]
-
def normalize_job_url(url)
-
uri = URI.parse(url.strip)
-
# Remove tracking parameters but keep job-specific ones
-
if uri.query.present?
-
params = URI.decode_www_form(uri.query).reject do |key, _|
-
# Remove common tracking params
-
%w[utm_source utm_medium utm_campaign utm_content utm_term ref source].include?(key.downcase)
-
end
-
uri.query = params.any? ? URI.encode_www_form(params) : nil
-
end
-
uri.to_s
-
rescue URI::InvalidURIError
-
url.strip
-
end
-
-
# Creates the interview application
-
#
-
# @param company [Company] The company
-
# @param job_role [JobRole] The job role
-
# @param job_listing [JobListing, nil] Optional job listing
-
# @return [InterviewApplication]
-
def create_application(company, job_role, job_listing = nil)
-
user.interview_applications.create!(
-
company: company,
-
job_role: job_role,
-
job_listing: job_listing,
-
applied_at: synced_email.email_date || Time.current,
-
notes: build_application_notes
-
)
-
end
-
-
# Normalizes company name
-
#
-
# @param name [String]
-
# @return [String]
-
def normalize_company_name(name)
-
normalized = name.strip
-
suffixes = [
-
/\s+inc\.?$/i,
-
/\s+llc\.?$/i,
-
/\s+corp\.?$/i,
-
/\s+ltd\.?$/i,
-
/\s+co\.?$/i
-
]
-
-
suffixes.each { |suffix| normalized = normalized.gsub(suffix, "") }
-
normalized.strip.titleize
-
end
-
-
# Builds notes for the application
-
# Uses emoji and clean formatting for readability
-
#
-
# @return [String]
-
def build_application_notes
-
lines = []
-
-
lines << "📬 Created from email signal"
-
lines << ""
-
-
# Recruiter section
-
if recruiter_name.present?
-
lines << "👤 RECRUITER"
-
lines << " #{recruiter_name}"
-
lines << " #{synced_email.signal_recruiter_title}" if synced_email.signal_recruiter_title.present?
-
lines << " #{recruiter_email}" if recruiter_email.present?
-
lines << ""
-
end
-
-
# Job details section
-
details = []
-
details << "📍 #{synced_email.signal_job_location}" if synced_email.signal_job_location.present?
-
details << "🏢 #{synced_email.signal_job_department}" if synced_email.signal_job_department.present?
-
details << "💰 #{synced_email.signal_job_salary_hint}" if synced_email.signal_job_salary_hint.present?
-
-
if details.any?
-
lines << "📋 DETAILS"
-
details.each { |d| lines << " #{d.sub(/^.{2}/, '')}" } # Remove emoji from sub-items
-
lines << ""
-
end
-
-
# Scheduling link (friendly name, not raw URL)
-
if scheduling_link.present?
-
friendly_name = extract_scheduling_platform(scheduling_link)
-
lines << "📅 NEXT STEP"
-
lines << " Schedule via #{friendly_name}"
-
end
-
-
lines.join("\n").strip
-
end
-
-
# Extracts a friendly platform name from scheduling URL
-
#
-
# @param url [String]
-
# @return [String]
-
def extract_scheduling_platform(url)
-
case url
-
when /goodtime\.io/i then "GoodTime"
-
when /calendly\.com/i then "Calendly"
-
when /cal\.com/i then "Cal.com"
-
when /doodle\.com/i then "Doodle"
-
when /zoom\.us.*schedule/i then "Zoom"
-
when /meet\.google/i then "Google Meet"
-
else "scheduling link"
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
# Service for processing application status change emails (rejection, offer)
-
#
-
# Processes emails classified as rejection or offer to update application status
-
# and create company feedback records.
-
#
-
# @example
-
# processor = Signals::ApplicationStatusProcessor.new(synced_email)
-
# result = processor.process
-
# if result[:success]
-
# # Application status updated
-
# end
-
#
-
class ApplicationStatusProcessor < ApplicationService
-
attr_reader :synced_email, :application
-
-
# Email types that this processor handles
-
PROCESSABLE_TYPES = %w[rejection offer].freeze
-
-
# Minimum confidence score to accept extraction results
-
MIN_CONFIDENCE_SCORE = 0.6
-
-
# Operation type for logging
-
OPERATION_TYPE = :application_status_extraction
-
-
# Initialize the processor
-
#
-
# @param synced_email [SyncedEmail] The email to process
-
def initialize(synced_email)
-
@synced_email = synced_email
-
@application = synced_email.interview_application
-
end
-
-
# Processes the email to update application status
-
#
-
# @return [Hash] Result with success status
-
def process
-
Rails.logger.info("[ApplicationStatusProcessor] Processing email ##{synced_email.id}: #{synced_email.subject}")
-
-
return skip_result("Email not matched to application") unless application
-
return skip_result("Email type not processable") unless processable?
-
return skip_result("No email content") unless content_available?
-
-
# Extract status data using LLM
-
extraction = extract_status_data
-
unless extraction[:success]
-
Rails.logger.warn("[ApplicationStatusProcessor] Extraction failed for email ##{synced_email.id}: #{extraction[:error]}")
-
return { success: false, error: extraction[:error] }
-
end
-
-
data = extraction[:data]
-
status_change = data[:status_change] || {}
-
-
result = case status_change[:type]
-
when "rejection"
-
handle_rejection(data)
-
when "offer"
-
handle_offer(data)
-
when "withdrawal", "ghosted", "on_hold"
-
handle_other_status(data)
-
else
-
skip_result("No status change detected")
-
end
-
-
result[:llm_api_log_id] = extraction[:llm_api_log_id] if extraction[:llm_api_log_id]
-
result
-
rescue StandardError => e
-
notify_error(
-
e,
-
context: "application_status_processor",
-
user: synced_email&.user,
-
synced_email_id: synced_email&.id,
-
application_id: application&.id,
-
email_type: synced_email&.email_type,
-
company: application&.company&.name
-
)
-
Rails.logger.error("[ApplicationStatusProcessor] Error processing email ##{synced_email&.id}: #{e.message}")
-
{ success: false, error: e.message }
-
end
-
-
private
-
-
# Checks if email type is processable
-
#
-
# @return [Boolean]
-
def processable?
-
PROCESSABLE_TYPES.include?(synced_email.email_type)
-
end
-
-
# Checks if email content is available
-
#
-
# @return [Boolean]
-
def content_available?
-
synced_email.body_preview.present? ||
-
synced_email.body_html.present? ||
-
synced_email.snippet.present?
-
end
-
-
# Returns skip result
-
#
-
# @param reason [String]
-
# @return [Hash]
-
def skip_result(reason)
-
Rails.logger.info("[ApplicationStatusProcessor] Skipped email ##{synced_email&.id}: #{reason}")
-
{ success: false, skipped: true, reason: reason }
-
end
-
-
# Extracts status data using LLM with observability
-
#
-
# @return [Hash] Result with success and data
-
def extract_status_data
-
prompt = build_prompt
-
prompt_template = Ai::StatusExtractionPrompt.active_prompt
-
system_message = prompt_template&.system_prompt.presence || Ai::StatusExtractionPrompt.default_system_prompt
-
-
runner = Ai::ProviderRunnerService.new(
-
provider_chain: provider_chain,
-
prompt: prompt,
-
content_size: extract_body_content.bytesize,
-
system_message: system_message,
-
provider_for: method(:get_provider_instance),
-
run_options: { max_tokens: 1500, temperature: 0.1 },
-
logger_builder: lambda { |provider_name, provider|
-
Ai::ApiLoggerService.new(
-
operation_type: OPERATION_TYPE,
-
loggable: synced_email,
-
provider: provider_name,
-
model: provider.respond_to?(:model_name) ? provider.model_name : "unknown",
-
llm_prompt: prompt_template
-
)
-
},
-
operation: OPERATION_TYPE,
-
loggable: synced_email,
-
user: synced_email&.user,
-
error_context: {
-
severity: "warning",
-
synced_email_id: synced_email&.id,
-
application_id: application&.id
-
}
-
)
-
-
result = runner.run do |response|
-
parsed = parse_response(response[:content])
-
status_change = parsed[:status_change] || {}
-
rejection = parsed[:rejection_details] || {}
-
offer = parsed[:offer_details] || {}
-
feedback = parsed[:feedback] || {}
-
-
log_data = {
-
confidence: parsed&.dig(:confidence_score),
-
status_type: status_change[:type],
-
is_final: status_change[:is_final],
-
sentiment: parsed[:sentiment],
-
has_feedback: feedback[:has_feedback],
-
rejection_reason: rejection[:reason].present?,
-
offer_role: offer[:role_title],
-
extracted_fields: extract_field_names(parsed)
-
}.compact
-
-
confidence_score = parsed[:confidence_score]
-
if confidence_score && confidence_score < MIN_CONFIDENCE_SCORE
-
Rails.logger.warn("[ApplicationStatusProcessor] Low confidence (#{confidence_score}) from provider")
-
end
-
accept = confidence_score.nil? || confidence_score >= MIN_CONFIDENCE_SCORE
-
[ parsed, log_data, accept ]
-
end
-
-
return { success: false, error: "Failed to extract status data from email" } unless result[:success]
-
-
status_type = result[:parsed].dig(:status_change, :type)
-
Rails.logger.info("[ApplicationStatusProcessor] Successfully extracted with #{result[:provider]} (confidence: #{result[:parsed]&.dig(:confidence_score)}, type: #{status_type})")
-
{
-
success: true,
-
data: result[:parsed],
-
provider: result[:provider],
-
llm_api_log_id: result[:llm_api_log_id],
-
latency_ms: result[:latency_ms]
-
}
-
end
-
-
# Extracts field names that were populated
-
#
-
# @param parsed [Hash]
-
# @return [Array<String>]
-
def extract_field_names(parsed)
-
fields = []
-
status_change = parsed[:status_change] || {}
-
rejection = parsed[:rejection_details] || {}
-
offer = parsed[:offer_details] || {}
-
feedback = parsed[:feedback] || {}
-
-
fields << "status_type" if status_change[:type].present?
-
fields << "is_final" unless status_change[:is_final].nil?
-
fields << "sentiment" if parsed[:sentiment].present?
-
fields << "rejection_reason" if rejection[:reason].present?
-
fields << "stage_rejected_at" if rejection[:stage_rejected_at].present?
-
fields << "offer_role" if offer[:role_title].present?
-
fields << "offer_start_date" if offer[:start_date].present?
-
fields << "response_deadline" if offer[:response_deadline].present?
-
fields << "feedback_text" if feedback[:feedback_text].present?
-
-
fields
-
end
-
-
# Builds the extraction prompt
-
#
-
# @return [String]
-
def build_prompt
-
subject = synced_email.subject || "(No subject)"
-
body = extract_body_content
-
from_email = synced_email.from_email || ""
-
from_name = synced_email.from_name || ""
-
company_name = application.company&.name || synced_email.signal_company_name || ""
-
current_status = application.pipeline_stage.to_s
-
vars = {
-
subject: subject,
-
body: body.truncate(5000),
-
from_email: from_email,
-
from_name: from_name,
-
company_name: company_name,
-
current_status: current_status
-
}
-
-
Ai::PromptBuilderService.new(
-
prompt_class: Ai::StatusExtractionPrompt,
-
variables: vars
-
).run
-
end
-
-
# Extracts body content from email
-
#
-
# @return [String]
-
def extract_body_content
-
if synced_email.body_preview.present?
-
synced_email.body_preview
-
elsif synced_email.body_html.present?
-
ActionController::Base.helpers.strip_tags(synced_email.body_html)
-
else
-
synced_email.snippet || ""
-
end
-
end
-
-
# Parses LLM response JSON
-
#
-
# @param content [String] Raw LLM response
-
# @return [Hash, nil]
-
def parse_response(content)
-
parsed = Ai::ResponseParserService.new(content).parse(symbolize: true)
-
return parsed if parsed
-
-
Rails.logger.warn("[ApplicationStatusProcessor] Failed to parse JSON")
-
nil
-
end
-
-
# Handles rejection emails
-
#
-
# @param data [Hash] Extracted data
-
# @return [Hash]
-
def handle_rejection(data)
-
Rails.logger.info("[ApplicationStatusProcessor] Handling rejection for application ##{application.id}")
-
-
# Only update if application is still active
-
if application.active?
-
begin
-
application.reject!
-
application.move_to_closed! if application.may_move_to_closed?
-
Rails.logger.info("[ApplicationStatusProcessor] Updated application ##{application.id} to rejected/closed")
-
rescue AASM::InvalidTransition => e
-
Rails.logger.warn("[ApplicationStatusProcessor] Could not transition to rejected: #{e.message}")
-
end
-
end
-
-
# Create company feedback
-
create_rejection_feedback(data)
-
-
{ success: true, action: :rejection, application: application }
-
end
-
-
# Handles offer emails
-
#
-
# @param data [Hash] Extracted data
-
# @return [Hash]
-
def handle_offer(data)
-
Rails.logger.info("[ApplicationStatusProcessor] Handling offer for application ##{application.id}")
-
-
# Move to offer stage
-
if application.may_move_to_offer?
-
application.move_to_offer!
-
Rails.logger.info("[ApplicationStatusProcessor] Moved application ##{application.id} to offer stage")
-
end
-
-
# Create company feedback with offer details
-
create_offer_feedback(data)
-
-
{ success: true, action: :offer, application: application }
-
end
-
-
# Handles other status changes (withdrawal, ghosted, on_hold)
-
#
-
# @param data [Hash] Extracted data
-
# @return [Hash]
-
def handle_other_status(data)
-
status_change = data[:status_change] || {}
-
status_type = status_change[:type]
-
-
Rails.logger.info("[ApplicationStatusProcessor] Handling #{status_type} for application ##{application.id}")
-
-
case status_type
-
when "withdrawal"
-
# Company withdrew the position
-
if application.active?
-
application.archive! if application.may_archive?
-
Rails.logger.info("[ApplicationStatusProcessor] Archived application ##{application.id} (withdrawal)")
-
end
-
create_generic_feedback(data, "Position withdrawn")
-
when "ghosted"
-
# Mark as potentially dead
-
create_generic_feedback(data, "No response - possible ghost")
-
when "on_hold"
-
# Create feedback noting the hold
-
create_generic_feedback(data, "Position/process on hold")
-
end
-
-
{ success: true, action: status_type.to_sym, application: application }
-
end
-
-
# Creates rejection feedback record
-
#
-
# @param data [Hash] Extracted data
-
def create_rejection_feedback(data)
-
rejection = data[:rejection_details] || {}
-
feedback = data[:feedback] || {}
-
feedback_text = feedback[:feedback_text].to_s.strip
-
feedback_text = rejection[:reason].to_s.strip if feedback_text.blank?
-
-
existing_feedback = application.company_feedback
-
if existing_feedback.present?
-
update_company_feedback(existing_feedback, feedback_text)
-
attach_rejection_feedback_to_latest_round(feedback_text)
-
return
-
end
-
-
fb = CompanyFeedback.create!(
-
interview_application: application,
-
source_email_id: synced_email.id,
-
feedback_type: "rejection",
-
feedback_text: feedback_text.presence,
-
rejection_reason: build_rejection_reason(rejection),
-
received_at: synced_email.email_date || Time.current,
-
self_reflection: nil, # User can add later
-
next_steps: rejection[:door_open] ? "Keep in touch for future opportunities" : nil
-
)
-
-
Rails.logger.info("[ApplicationStatusProcessor] Created rejection CompanyFeedback ##{fb.id}")
-
attach_rejection_feedback_to_latest_round(feedback_text)
-
rescue ActiveRecord::RecordInvalid => e
-
Rails.logger.warn("[ApplicationStatusProcessor] Failed to create rejection feedback: #{e.message}")
-
end
-
-
# Updates existing company feedback with new text
-
#
-
# @param existing_feedback [CompanyFeedback]
-
# @param feedback_text [String]
-
def update_company_feedback(existing_feedback, feedback_text)
-
return if feedback_text.blank?
-
-
existing_text = existing_feedback.feedback_text.to_s.strip
-
return if existing_text.include?(feedback_text)
-
-
updated_text = [ existing_text.presence, feedback_text.presence ].compact.join("\n\n")
-
existing_feedback.update!(feedback_text: updated_text)
-
rescue ActiveRecord::RecordInvalid => e
-
Rails.logger.warn("[ApplicationStatusProcessor] Failed to update rejection feedback: #{e.message}")
-
end
-
-
# Attaches rejection feedback to the latest round (if any)
-
#
-
# @param feedback_text [String]
-
def attach_rejection_feedback_to_latest_round(feedback_text)
-
return if feedback_text.blank?
-
-
round = application.latest_round
-
return unless round
-
return if round.interview_feedback.present?
-
-
InterviewFeedback.create!(
-
interview_round: round,
-
went_well: nil,
-
to_improve: nil,
-
ai_summary: nil,
-
interviewer_notes: feedback_text,
-
recommended_action: "Review feedback and apply learnings to future interviews"
-
)
-
rescue ActiveRecord::RecordInvalid => e
-
Rails.logger.warn("[ApplicationStatusProcessor] Failed to create round feedback: #{e.message}")
-
end
-
-
# Builds rejection reason text
-
#
-
# @param rejection [Hash]
-
# @return [String]
-
def build_rejection_reason(rejection)
-
parts = []
-
parts << rejection[:reason] if rejection[:reason].present?
-
parts << "Rejected at: #{rejection[:stage_rejected_at]} stage" if rejection[:stage_rejected_at].present?
-
parts << "(Generic rejection email)" if rejection[:is_generic]
-
-
parts.join("\n")
-
end
-
-
# Creates offer feedback record
-
#
-
# @param data [Hash] Extracted data
-
def create_offer_feedback(data)
-
offer = data[:offer_details] || {}
-
feedback = data[:feedback] || {}
-
-
# Don't create duplicate feedback
-
return if application.company_feedback.present?
-
-
next_steps = []
-
next_steps << offer[:next_steps] if offer[:next_steps].present?
-
next_steps << "Respond by: #{offer[:response_deadline]}" if offer[:response_deadline].present?
-
next_steps << "Start date: #{offer[:start_date]}" if offer[:start_date].present?
-
-
fb = CompanyFeedback.create!(
-
interview_application: application,
-
source_email_id: synced_email.id,
-
feedback_type: "offer",
-
feedback_text: build_offer_text(offer, feedback),
-
rejection_reason: nil,
-
received_at: synced_email.email_date || Time.current,
-
self_reflection: nil,
-
next_steps: next_steps.join("\n")
-
)
-
-
Rails.logger.info("[ApplicationStatusProcessor] Created offer CompanyFeedback ##{fb.id}")
-
rescue ActiveRecord::RecordInvalid => e
-
Rails.logger.warn("[ApplicationStatusProcessor] Failed to create offer feedback: #{e.message}")
-
end
-
-
# Builds offer text
-
#
-
# @param offer [Hash]
-
# @param feedback [Hash]
-
# @return [String]
-
def build_offer_text(offer, feedback)
-
parts = []
-
parts << "🎉 Offer received!"
-
parts << "Role: #{offer[:role_title]}" if offer[:role_title].present?
-
parts << "Department: #{offer[:department]}" if offer[:department].present?
-
parts << feedback[:feedback_text] if feedback[:feedback_text].present?
-
-
parts.join("\n")
-
end
-
-
# Creates generic feedback record
-
#
-
# @param data [Hash]
-
# @param summary [String]
-
def create_generic_feedback(data, summary)
-
feedback = data[:feedback] || {}
-
-
# Don't create duplicate feedback
-
return if application.company_feedback.present?
-
-
fb = CompanyFeedback.create!(
-
interview_application: application,
-
source_email_id: synced_email.id,
-
feedback_type: "general",
-
feedback_text: "#{summary}\n\n#{feedback[:feedback_text]}".strip,
-
received_at: synced_email.email_date || Time.current
-
)
-
-
Rails.logger.info("[ApplicationStatusProcessor] Created generic CompanyFeedback ##{fb.id}")
-
rescue ActiveRecord::RecordInvalid => e
-
Rails.logger.warn("[ApplicationStatusProcessor] Failed to create feedback: #{e.message}")
-
end
-
-
# Returns provider chain for LLM
-
#
-
# @return [Array<String>]
-
def provider_chain
-
LlmProviders::ProviderConfigHelper.all_providers
-
end
-
-
# Gets provider instance
-
#
-
# @param provider_name [String]
-
# @return [Object, nil]
-
def get_provider_instance(provider_name)
-
case provider_name.to_s.downcase
-
when "openai" then LlmProviders::OpenaiProvider.new
-
when "anthropic" then LlmProviders::AnthropicProvider.new
-
when "ollama" then LlmProviders::OllamaProvider.new
-
else nil
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
# Service for capturing company feedback from various email types
-
#
-
# This processor creates CompanyFeedback records from emails that contain
-
# feedback about the candidate, even if they're not explicit rejection/offer emails.
-
# It works as a secondary processor alongside ApplicationStatusProcessor.
-
#
-
# @example
-
# processor = Signals::CompanyFeedbackProcessor.new(synced_email)
-
# result = processor.process
-
# if result[:success]
-
# # Feedback captured
-
# end
-
#
-
class CompanyFeedbackProcessor < ApplicationService
-
attr_reader :synced_email, :application
-
-
# Initialize the processor
-
#
-
# @param synced_email [SyncedEmail] The email to process
-
def initialize(synced_email)
-
@synced_email = synced_email
-
@application = synced_email.interview_application
-
end
-
-
# Processes the email to create company feedback
-
#
-
# @return [Hash] Result with success status
-
def process
-
return skip_result("Email not matched to application") unless application
-
return skip_result("Feedback already exists for this email") if feedback_exists_for_email?
-
return skip_result("No email content") unless content_available?
-
-
# Check if there's extractable feedback in the email
-
feedback_data = extract_feedback_from_signals
-
-
if feedback_data[:has_feedback]
-
feedback = create_feedback_record(feedback_data)
-
if feedback&.persisted?
-
{ success: true, feedback: feedback, action: :created }
-
else
-
{ success: false, error: "Failed to create feedback record" }
-
end
-
else
-
skip_result("No feedback content found in email")
-
end
-
rescue StandardError => e
-
notify_error(
-
e,
-
context: "company_feedback_processor",
-
user: synced_email&.user,
-
synced_email_id: synced_email&.id,
-
application_id: application&.id
-
)
-
{ success: false, error: e.message }
-
end
-
-
private
-
-
# Checks if email content is available
-
#
-
# @return [Boolean]
-
def content_available?
-
synced_email.body_preview.present? ||
-
synced_email.body_html.present? ||
-
synced_email.snippet.present?
-
end
-
-
# Checks if feedback already exists for this email
-
#
-
# @return [Boolean]
-
def feedback_exists_for_email?
-
CompanyFeedback.exists?(source_email_id: synced_email.id)
-
end
-
-
# Returns skip result
-
#
-
# @param reason [String]
-
# @return [Hash]
-
def skip_result(reason)
-
{ success: false, skipped: true, reason: reason }
-
end
-
-
# Extracts feedback data from already-extracted signals
-
#
-
# @return [Hash]
-
def extract_feedback_from_signals
-
# Use existing extracted data if available
-
extracted = synced_email.extracted_data || {}
-
-
# Check if there's feedback in the extracted signals
-
feedback_text = extracted.dig("feedback", "feedback_text") ||
-
extracted.dig("feedback_text")
-
key_insights = extracted["key_insights"]
-
-
has_feedback = feedback_text.present? || key_insights.present?
-
-
{
-
has_feedback: has_feedback,
-
feedback_text: feedback_text,
-
key_insights: key_insights,
-
feedback_type: determine_feedback_type
-
}
-
end
-
-
# Determines the feedback type based on email type
-
#
-
# @return [String]
-
def determine_feedback_type
-
case synced_email.email_type
-
when "rejection" then "rejection"
-
when "offer" then "offer"
-
when "round_feedback" then "general"
-
else "general"
-
end
-
end
-
-
# Creates company feedback record
-
#
-
# @param data [Hash]
-
# @return [CompanyFeedback, nil]
-
def create_feedback_record(data)
-
# Don't duplicate if feedback exists for the application from the same source
-
return nil if application.company_feedback.present? && data[:feedback_type] != "general"
-
-
feedback_text = build_feedback_text(data)
-
return nil if feedback_text.blank?
-
-
CompanyFeedback.create!(
-
interview_application: application,
-
source_email_id: synced_email.id,
-
feedback_type: data[:feedback_type],
-
feedback_text: feedback_text,
-
received_at: synced_email.received_at || Time.current
-
)
-
rescue ActiveRecord::RecordInvalid => e
-
Rails.logger.warn("CompanyFeedbackProcessor: Failed to create feedback: #{e.message}")
-
nil
-
end
-
-
# Builds feedback text from extracted data
-
#
-
# @param data [Hash]
-
# @return [String]
-
def build_feedback_text(data)
-
parts = []
-
-
if data[:feedback_text].present?
-
parts << data[:feedback_text]
-
end
-
-
if data[:key_insights].present? && data[:feedback_text].blank?
-
parts << "Key Insights:\n#{data[:key_insights]}"
-
end
-
-
parts.join("\n\n").strip
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
require "set"
-
-
module Signals
-
# Orchestrates processor execution and application state updates.
-
class EmailStateOrchestrator < ApplicationService
-
PROCESSOR_ACTIONS = %i[
-
run_interview_round_processor
-
run_round_feedback_processor
-
run_status_processor
-
].freeze
-
-
attr_reader :synced_email, :context, :planner
-
-
def initialize(synced_email)
-
@synced_email = synced_email
-
@context = Signals::StateContext.new(synced_email)
-
@planner = Signals::StateTransitionPlanner.new(context)
-
end
-
-
def call
-
unless context.matched?
-
log_info("Skipped email #{synced_email.id}: not matched to application")
-
return { success: false, skipped: true, reason: "Email not matched to application" }
-
end
-
-
log_info("Processing email #{synced_email.id} (type: #{synced_email.email_type})")
-
actions = planner.plan
-
processor_actions, state_actions = partition_actions(actions)
-
-
processor_results = run_processors(processor_actions)
-
applied_actions = Signals::ActionApplier.new(context).apply!(state_actions)
-
-
{
-
success: true,
-
actions: actions,
-
processor_results: processor_results,
-
applied_actions: applied_actions
-
}
-
rescue StandardError => e
-
notify_error(
-
e,
-
context: "signal_email_orchestrator",
-
user: synced_email&.user,
-
synced_email_id: synced_email&.id,
-
application_id: context.application&.id,
-
email_type: synced_email&.email_type
-
)
-
log_error("Failed to process email #{synced_email&.id}: #{e.message}")
-
{ success: false, error: e.message }
-
end
-
-
private
-
-
def partition_actions(actions)
-
processor_actions = actions.select { |action| PROCESSOR_ACTIONS.include?(action[:type]) }
-
state_actions = actions.reject { |action| PROCESSOR_ACTIONS.include?(action[:type]) }
-
-
[ dedupe_actions(processor_actions), state_actions ]
-
end
-
-
def dedupe_actions(actions)
-
seen = Set.new
-
actions.each_with_object([]) do |action, list|
-
next if seen.include?(action[:type])
-
-
seen << action[:type]
-
list << action
-
end
-
end
-
-
def run_processors(actions)
-
actions.each_with_object({}) do |action, results|
-
case action[:type]
-
when :run_interview_round_processor
-
results[:interview_round] = Signals::InterviewRoundProcessor.new(synced_email).process
-
when :run_round_feedback_processor
-
results[:round_feedback] = Signals::RoundFeedbackProcessor.new(synced_email).process
-
when :run_status_processor
-
results[:application_status] = Signals::ApplicationStatusProcessor.new(synced_email).process
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
# Service for AI-powered extraction of actionable signals from synced emails
-
#
-
# Uses configured LLM providers to extract structured intelligence including
-
# company info, recruiter details, job information, relevant links, and
-
# suggested actions from email content.
-
#
-
# @example
-
# service = Signals::ExtractionService.new(synced_email)
-
# result = service.extract
-
# if result[:success]
-
# # Email has been updated with extracted signals
-
# end
-
#
-
class ExtractionService < ApplicationService
-
attr_reader :synced_email
-
-
# Minimum confidence score to accept extraction results
-
MIN_CONFIDENCE_SCORE = 0.5
-
MIN_PREVIEW_LENGTH = 200
-
-
# Initialize the service
-
#
-
# @param synced_email [SyncedEmail] The email to extract signals from
-
def initialize(synced_email)
-
@synced_email = synced_email
-
end
-
-
# Extracts signals from the email using AI
-
#
-
# @return [Hash] Result with success status and extracted data
-
def extract
-
return skip_extraction("No email content available") unless email_content_available?
-
return skip_extraction("Email type not suitable for extraction") unless should_extract?
-
-
synced_email.mark_extraction_processing!
-
-
# Build prompt with email content
-
prompt = build_prompt
-
-
# Try extraction with LLM providers
-
result = extract_with_llm(prompt)
-
-
if result[:success]
-
update_email_with_signals(result[:data])
-
result
-
else
-
synced_email.mark_extraction_failed!(result[:error])
-
{ success: false, error: result[:error] || "Extraction failed" }
-
end
-
rescue StandardError => e
-
notify_error(
-
e,
-
context: "signal_extraction_service",
-
user: synced_email&.user,
-
synced_email_id: synced_email&.id,
-
email_type: synced_email&.email_type
-
)
-
synced_email.mark_extraction_failed!(e.message)
-
{ success: false, error: e.message }
-
end
-
-
private
-
-
# Checks if email content is available for extraction
-
#
-
# @return [Boolean]
-
def email_content_available?
-
synced_email.body_preview.present? ||
-
synced_email.body_html.present? ||
-
synced_email.snippet.present? ||
-
synced_email.subject.present?
-
end
-
-
# Determines if this email should have signals extracted
-
# Skip extraction for clearly irrelevant emails
-
#
-
# @return [Boolean]
-
def should_extract?
-
# Skip auto-ignored and ignored emails
-
return false if synced_email.auto_ignored? || synced_email.ignored?
-
-
# Skip emails classified as "other" that aren't matched
-
return false if synced_email.email_type == "other" && !synced_email.matched?
-
-
true
-
end
-
-
# Skips extraction with a reason
-
#
-
# @param reason [String]
-
# @return [Hash]
-
def skip_extraction(reason)
-
synced_email.mark_extraction_skipped!
-
{ success: false, skipped: true, reason: reason }
-
end
-
-
# Builds the extraction prompt with email content
-
#
-
# @return [String]
-
def build_prompt
-
subject = synced_email.subject || "(No subject)"
-
body = extract_body_content
-
from_email = synced_email.from_email || ""
-
from_name = synced_email.from_name || ""
-
email_type = synced_email.email_type || "unknown"
-
vars = {
-
subject: subject,
-
body: body.truncate(6000),
-
from_email: from_email,
-
from_name: from_name,
-
email_type: email_type
-
}
-
-
Ai::PromptBuilderService.new(
-
prompt_class: Ai::SignalExtractionPrompt,
-
variables: vars
-
).run
-
end
-
-
# Extracts the best available body content
-
#
-
# @return [String]
-
def extract_body_content
-
# Prefer preview if it's substantial; otherwise fall back to cleaned HTML text
-
if synced_email.body_preview.present? && synced_email.body_preview.length >= MIN_PREVIEW_LENGTH
-
normalize_text(synced_email.body_preview)
-
elsif synced_email.body_html.present?
-
normalize_text(extract_text_from_html(synced_email.body_html))
-
else
-
normalize_text(synced_email.snippet || "")
-
end
-
end
-
-
# Extracts data using LLM providers
-
#
-
# @param prompt [String] The extraction prompt
-
# @return [Hash] Result with success and data
-
def extract_with_llm(prompt)
-
prompt_template = Ai::SignalExtractionPrompt.active_prompt
-
system_message = prompt_template&.system_prompt.presence || Ai::SignalExtractionPrompt.default_system_prompt
-
-
runner = Ai::ProviderRunnerService.new(
-
provider_chain: provider_chain,
-
prompt: prompt,
-
content_size: extract_body_content.bytesize,
-
system_message: system_message,
-
provider_for: method(:get_provider_instance),
-
run_options: { max_tokens: 2000, temperature: 0.1 },
-
logger_builder: lambda { |provider_name, provider|
-
Ai::ApiLoggerService.new(
-
operation_type: :signal_extraction,
-
loggable: synced_email,
-
provider: provider_name,
-
model: provider.respond_to?(:model_name) ? provider.model_name : "unknown",
-
llm_prompt: prompt_template
-
)
-
},
-
operation: :signal_extraction,
-
loggable: synced_email,
-
user: synced_email&.user,
-
error_context: {
-
severity: "warning",
-
synced_email_id: synced_email&.id,
-
email_type: synced_email&.email_type
-
}
-
)
-
-
result = runner.run do |response|
-
parsed = parse_response(response[:content])
-
log_data = {
-
confidence: parsed&.dig(:confidence_score),
-
company_name: parsed&.dig(:company, :name),
-
recruiter_name: parsed&.dig(:recruiter, :name),
-
job_title: parsed&.dig(:job, :title),
-
suggested_actions: parsed&.dig(:suggested_actions)
-
}.compact
-
accept = parsed[:confidence_score] && parsed[:confidence_score] >= MIN_CONFIDENCE_SCORE
-
[ parsed, log_data, accept ]
-
end
-
-
return { success: true, data: result[:parsed], provider: result[:provider] } if result[:success]
-
-
{ success: false, error: result[:error] || "All providers failed or returned low confidence" }
-
end
-
-
# Returns the provider chain
-
#
-
# @return [Array<String>]
-
def provider_chain
-
LlmProviders::ProviderConfigHelper.all_providers
-
end
-
-
# Gets a provider instance
-
#
-
# @param provider_name [String]
-
# @return [LlmProviders::BaseProvider, nil]
-
def get_provider_instance(provider_name)
-
case provider_name.to_s.downcase
-
when "openai"
-
LlmProviders::OpenaiProvider.new
-
when "anthropic"
-
LlmProviders::AnthropicProvider.new
-
when "ollama"
-
LlmProviders::OllamaProvider.new
-
else
-
nil
-
end
-
end
-
-
# Parses the LLM response
-
#
-
# @param response_text [String]
-
# @return [Hash]
-
def parse_response(response_text)
-
parsed = Ai::ResponseParserService.new(response_text).parse(symbolize: true)
-
parsed || { confidence_score: 0.0 }
-
end
-
-
# Updates the email with extracted signal data
-
#
-
# @param data [Hash] Extracted data from LLM
-
# @return [void]
-
def update_email_with_signals(data)
-
extracted = {}
-
-
# Company information
-
if data[:company].is_a?(Hash)
-
extracted[:signal_company_name] = data[:company][:name] if data[:company][:name].present?
-
extracted[:signal_company_website] = data[:company][:website] if data[:company][:website].present?
-
extracted[:signal_company_careers_url] = data[:company][:careers_url] if data[:company][:careers_url].present?
-
extracted[:signal_company_domain] = data[:company][:domain] if data[:company][:domain].present?
-
end
-
-
# Recruiter information
-
if data[:recruiter].is_a?(Hash)
-
extracted[:signal_recruiter_name] = data[:recruiter][:name] if data[:recruiter][:name].present?
-
extracted[:signal_recruiter_email] = data[:recruiter][:email] if data[:recruiter][:email].present?
-
extracted[:signal_recruiter_title] = data[:recruiter][:title] if data[:recruiter][:title].present?
-
extracted[:signal_recruiter_linkedin] = data[:recruiter][:linkedin_url] if data[:recruiter][:linkedin_url].present?
-
end
-
-
# Job information
-
if data[:job].is_a?(Hash)
-
extracted[:signal_job_title] = data[:job][:title] if data[:job][:title].present?
-
extracted[:signal_job_department] = data[:job][:department] if data[:job][:department].present?
-
extracted[:signal_job_location] = data[:job][:location] if data[:job][:location].present?
-
extracted[:signal_job_url] = data[:job][:url] if data[:job][:url].present?
-
extracted[:signal_job_salary_hint] = data[:job][:salary_hint] if data[:job][:salary_hint].present?
-
end
-
-
# Action links (LLM-classified URLs with dynamic labels)
-
if data[:action_links].is_a?(Array)
-
# Normalize and filter links to remove duplicates/noise
-
normalized_links = data[:action_links].map do |link|
-
{
-
"url" => link[:url].to_s,
-
"action_label" => link[:action_label].to_s,
-
"priority" => (link[:priority] || 5).to_i
-
}
-
end.select { |link| link["url"].present? && link["action_label"].present? }
-
-
extracted[:signal_action_links] = filter_action_links(normalized_links)
-
end
-
-
# Suggested backend actions
-
if data[:suggested_actions].is_a?(Array)
-
# Filter to only valid actions
-
valid_actions = data[:suggested_actions] & SyncedEmail::SUGGESTED_ACTIONS
-
extracted[:signal_suggested_actions] = valid_actions if valid_actions.any?
-
end
-
-
# Store additional metadata
-
extracted[:key_insights] = data[:key_insights] if data[:key_insights].present?
-
extracted[:is_forwarded] = data[:is_forwarded] if data[:is_forwarded].present?
-
extracted[:raw_extraction] = data
-
extracted[:extracted_at] = Time.current.iso8601
-
-
synced_email.update_extraction!(extracted, confidence: data[:confidence_score])
-
-
# Automatically save company and recruiter information
-
save_company_and_recruiter(extracted)
-
end
-
-
# Automatically saves company and recruiter information from extracted signals
-
# This builds the recruiter directory and company database without user action
-
#
-
# @param extracted [Hash] The extracted signal data
-
# @return [void]
-
def save_company_and_recruiter(extracted)
-
company = nil
-
-
# Create or find company if we have a name
-
if extracted[:signal_company_name].present?
-
company = find_or_create_company(extracted)
-
end
-
-
# Enrich the email sender with recruiter info
-
enrich_email_sender(extracted, company)
-
rescue StandardError => e
-
# Don't fail extraction if company/recruiter save fails
-
Rails.logger.warn("Failed to save company/recruiter for email #{synced_email.id}: #{e.message}")
-
end
-
-
# Finds or creates a company from extracted signal data
-
# Note: Company is a global model, not user-scoped
-
#
-
# @param extracted [Hash] The extracted signal data
-
# @return [Company, nil]
-
def find_or_create_company(extracted)
-
return nil unless extracted[:signal_company_name].present?
-
-
# Try to find existing company by name (case-insensitive)
-
existing = Company.where("LOWER(name) = ?", extracted[:signal_company_name].downcase).first
-
-
if existing
-
# Update with any new info we have
-
updates = {}
-
updates[:website] = extracted[:signal_company_website] if extracted[:signal_company_website].present? && existing.website.blank?
-
existing.update!(updates) if updates.any?
-
return existing
-
end
-
-
# Create new company with extracted data
-
Company.create!(
-
name: extracted[:signal_company_name],
-
website: extracted[:signal_company_website]
-
)
-
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e
-
Rails.logger.warn("Failed to create company #{extracted[:signal_company_name]}: #{e.message}")
-
# Try to find again in case of race condition
-
Company.where("LOWER(name) = ?", extracted[:signal_company_name].downcase).first
-
end
-
-
# Enriches the email sender record with extracted recruiter information
-
#
-
# @param extracted [Hash] The extracted signal data
-
# @param company [Company, nil] The associated company
-
# @return [void]
-
def enrich_email_sender(extracted, company)
-
# Get or create the email sender
-
sender = synced_email.email_sender
-
sender ||= EmailSender.find_or_create_from_email(
-
synced_email.from_email,
-
extracted[:signal_recruiter_name] || synced_email.from_name
-
)
-
return unless sender
-
-
updates = {}
-
-
# Update name if we have a better one from extraction
-
if extracted[:signal_recruiter_name].present? && sender.name.blank?
-
updates[:name] = extracted[:signal_recruiter_name]
-
end
-
-
# Update title from extraction (if model supports it)
-
if extracted[:signal_recruiter_title].present? && sender.respond_to?(:title=)
-
updates[:title] = extracted[:signal_recruiter_title]
-
end
-
-
# Update LinkedIn URL from extraction (if model supports it)
-
if extracted[:signal_recruiter_linkedin].present? && sender.respond_to?(:linkedin_url=)
-
updates[:linkedin_url] = extracted[:signal_recruiter_linkedin]
-
end
-
-
# Set sender type to recruiter if we detect recruiter title
-
if extracted[:signal_recruiter_title].present? &&
-
extracted[:signal_recruiter_title].downcase.match?(/recruit|talent|sourcing/i)
-
updates[:sender_type] = "recruiter"
-
end
-
-
# Link to company if not already linked
-
if company && !sender.has_company?
-
updates[:auto_detected_company] = company
-
end
-
-
# Update last seen
-
updates[:last_seen_at] = Time.current
-
-
sender.update!(updates) if updates.any?
-
-
# Link sender to email if not already
-
synced_email.update!(email_sender: sender) unless synced_email.email_sender_id == sender.id
-
end
-
-
# Extracts text content from HTML while removing noisy elements.
-
#
-
# @param html [String]
-
# @return [String]
-
def extract_text_from_html(html)
-
fragment = Nokogiri::HTML::DocumentFragment.parse(html)
-
fragment.css("style, script, noscript, head, title, meta, link").remove
-
-
fragment.css("*[style]").each do |node|
-
style = node["style"].to_s.downcase
-
node.remove if style.include?("display:none") || style.include?("visibility:hidden")
-
end
-
-
fragment.traverse do |node|
-
node.remove if node.comment?
-
end
-
-
fragment.text
-
rescue StandardError
-
ActionController::Base.helpers.strip_tags(html.to_s)
-
end
-
-
# Normalizes text by collapsing whitespace and trimming.
-
#
-
# @param text [String]
-
# @return [String]
-
def normalize_text(text)
-
text.to_s.gsub(/\s+/, " ").strip
-
end
-
-
# Filters action links to remove duplicates and low-value URLs.
-
#
-
# @param links [Array<Hash>]
-
# @return [Array<Hash>]
-
def filter_action_links(links)
-
return [] if links.blank?
-
-
ignore_label_patterns = [
-
/unsubscribe/i,
-
/view in browser/i,
-
/privacy/i,
-
/terms/i,
-
/learn more/i,
-
/forwarding/i,
-
/event details/i
-
]
-
-
ignore_url_patterns = [
-
%r{calendar\.google\.com}i,
-
%r{google\.com/calendar}i,
-
%r{support\.google\.com}i
-
]
-
-
seen = {}
-
links.sort_by { |link| link["priority"] || 5 }.each_with_object([]) do |link, filtered|
-
url = link["url"].to_s.strip
-
label = link["action_label"].to_s.strip
-
next if url.blank? || label.blank?
-
-
next if ignore_label_patterns.any? { |pattern| label.match?(pattern) }
-
next if ignore_url_patterns.any? { |pattern| url.match?(pattern) } &&
-
!label.match?(/schedule|reschedule|join/i)
-
-
key = canonical_url_key(url)
-
next if key.present? && seen[key]
-
-
seen[key] = true if key.present?
-
filtered << link
-
end
-
end
-
-
# Canonicalizes URLs for deduplication by stripping tracking params.
-
#
-
# @param url [String]
-
# @return [String, nil]
-
def canonical_url_key(url)
-
uri = URI.parse(url)
-
return nil unless uri.host
-
-
# Unwrap common redirect URLs (e.g., Google Calendar links)
-
if uri.host.match?(/google\.com/i) && uri.path == "/url"
-
params = URI.decode_www_form(uri.query.to_s).to_h
-
redirected = params["q"] || params["url"]
-
return canonical_url_key(redirected) if redirected.present?
-
end
-
-
params = URI.decode_www_form(uri.query.to_s).reject do |(key, _)|
-
key.match?(/\Autm_/i) || %w[gclid fbclid mc_cid mc_eid].include?(key)
-
end
-
-
uri.query = params.any? ? URI.encode_www_form(params) : nil
-
uri.fragment = nil
-
-
normalized = "#{uri.scheme}://#{uri.host}#{uri.path}"
-
normalized += "?#{uri.query}" if uri.query.present?
-
normalized.sub(%r{/\z}, "")
-
rescue StandardError
-
nil
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
# Service for automatically creating interview rounds from scheduling confirmation emails
-
#
-
# Processes emails classified as scheduling, interview_invite, or interview_reminder
-
# to extract interview details and create InterviewRound records.
-
#
-
# @example
-
# processor = Signals::InterviewRoundProcessor.new(synced_email)
-
# result = processor.process
-
# if result[:success]
-
# # Interview round created or updated
-
# end
-
#
-
class InterviewRoundProcessor < ApplicationService
-
attr_reader :synced_email, :application
-
-
# Email types that this processor handles
-
PROCESSABLE_TYPES = %w[scheduling interview_invite interview_reminder].freeze
-
-
# Minimum confidence score to accept extraction results
-
MIN_CONFIDENCE_SCORE = 0.5
-
-
# Operation type for logging
-
OPERATION_TYPE = :interview_round_extraction
-
-
# Initialize the processor
-
#
-
# @param synced_email [SyncedEmail] The email to process
-
def initialize(synced_email)
-
@synced_email = synced_email
-
@application = synced_email.interview_application
-
end
-
-
# Processes the email to create/update interview round
-
#
-
# @return [Hash] Result with success status and round
-
def process
-
Rails.logger.info("[InterviewRoundProcessor] Processing email ##{synced_email.id}: #{synced_email.subject}")
-
-
return skip_result("Email not matched to application") unless application
-
return skip_result("Email type not processable") unless processable?
-
return skip_result("No email content") unless content_available?
-
-
# Check if we already processed this email
-
existing_round = InterviewRound.find_by(source_email_id: synced_email.id)
-
if existing_round
-
Rails.logger.info("[InterviewRoundProcessor] Email ##{synced_email.id} already processed -> Round ##{existing_round.id}")
-
return skip_result("Already processed", round: existing_round)
-
end
-
-
# Extract interview details using LLM
-
extraction = extract_interview_data
-
unless extraction[:success]
-
Rails.logger.warn("[InterviewRoundProcessor] Extraction failed for email ##{synced_email.id}: #{extraction[:error]}")
-
return { success: false, error: extraction[:error] }
-
end
-
-
data = extraction[:data]
-
scheduled_at = parse_scheduled_time(data.dig(:interview, :scheduled_at))
-
unless scheduling_signal_present?(data, scheduled_at)
-
return skip_result("Insufficient scheduling signal")
-
end
-
-
# Create or update interview round
-
round = create_or_update_round(data, scheduled_at)
-
-
if round.persisted?
-
Rails.logger.info("[InterviewRoundProcessor] Created round ##{round.id} for email ##{synced_email.id}")
-
{ success: true, round: round, action: :created, llm_api_log_id: extraction[:llm_api_log_id] }
-
else
-
Rails.logger.error("[InterviewRoundProcessor] Failed to persist round: #{round.errors.full_messages.join(', ')}")
-
{ success: false, error: round.errors.full_messages.join(", ") }
-
end
-
rescue StandardError => e
-
notify_error(
-
e,
-
context: "interview_round_processor",
-
user: synced_email&.user,
-
synced_email_id: synced_email&.id,
-
application_id: application&.id,
-
email_type: synced_email&.email_type,
-
company: application&.company&.name
-
)
-
Rails.logger.error("[InterviewRoundProcessor] Error processing email ##{synced_email&.id}: #{e.message}")
-
{ success: false, error: e.message }
-
end
-
-
private
-
-
# Checks if email type is processable
-
#
-
# @return [Boolean]
-
def processable?
-
PROCESSABLE_TYPES.include?(synced_email.email_type)
-
end
-
-
# Checks if email content is available
-
#
-
# @return [Boolean]
-
def content_available?
-
synced_email.body_preview.present? ||
-
synced_email.body_html.present? ||
-
synced_email.snippet.present?
-
end
-
-
# Returns skip result
-
#
-
# @param reason [String]
-
# @param data [Hash] Additional data
-
# @return [Hash]
-
def skip_result(reason, data = {})
-
Rails.logger.info("[InterviewRoundProcessor] Skipped email ##{synced_email&.id}: #{reason}")
-
{ success: false, skipped: true, reason: reason }.merge(data)
-
end
-
-
# Extracts interview data using LLM with observability
-
#
-
# @return [Hash] Result with success and data
-
def extract_interview_data
-
prompt = build_prompt
-
prompt_template = Ai::InterviewExtractionPrompt.active_prompt
-
system_message = prompt_template&.system_prompt.presence || Ai::InterviewExtractionPrompt.default_system_prompt
-
-
runner = Ai::ProviderRunnerService.new(
-
provider_chain: provider_chain,
-
prompt: prompt,
-
content_size: extract_body_content.bytesize,
-
system_message: system_message,
-
provider_for: method(:get_provider_instance),
-
run_options: { max_tokens: 1500, temperature: 0.1 },
-
logger_builder: lambda { |provider_name, provider|
-
Ai::ApiLoggerService.new(
-
operation_type: OPERATION_TYPE,
-
loggable: synced_email,
-
provider: provider_name,
-
model: provider.respond_to?(:model_name) ? provider.model_name : "unknown",
-
llm_prompt: prompt_template
-
)
-
},
-
operation: OPERATION_TYPE,
-
loggable: synced_email,
-
user: synced_email&.user,
-
error_context: {
-
severity: "warning",
-
synced_email_id: synced_email&.id,
-
application_id: application&.id
-
}
-
)
-
-
result = runner.run do |response|
-
parsed = parse_response(response[:content])
-
interview = parsed[:interview] || {}
-
interviewer = parsed[:interviewer] || {}
-
logistics = parsed[:logistics] || {}
-
-
log_data = {
-
confidence: parsed&.dig(:confidence_score),
-
scheduled_at: interview[:scheduled_at],
-
duration_minutes: interview[:duration_minutes],
-
stage: interview[:stage],
-
interviewer_name: interviewer[:name],
-
video_link: logistics[:video_link].present?,
-
confirmation_source: parsed[:confirmation_source],
-
extracted_fields: extract_field_names(parsed)
-
}.compact
-
-
confidence_score = parsed[:confidence_score]
-
if confidence_score && confidence_score < MIN_CONFIDENCE_SCORE
-
Rails.logger.warn("[InterviewRoundProcessor] Low confidence (#{confidence_score}) from provider")
-
end
-
accept = confidence_score.nil? || confidence_score >= MIN_CONFIDENCE_SCORE
-
[ parsed, log_data, accept ]
-
end
-
-
return { success: false, error: "Failed to extract interview data from email" } unless result[:success]
-
-
Rails.logger.info("[InterviewRoundProcessor] Successfully extracted with #{result[:provider]} (confidence: #{result[:parsed]&.dig(:confidence_score)})")
-
{
-
success: true,
-
data: result[:parsed],
-
provider: result[:provider],
-
llm_api_log_id: result[:llm_api_log_id],
-
latency_ms: result[:latency_ms]
-
}
-
end
-
-
# Extracts field names that were populated
-
#
-
# @param parsed [Hash]
-
# @return [Array<String>]
-
def extract_field_names(parsed)
-
fields = []
-
interview = parsed[:interview] || {}
-
interviewer = parsed[:interviewer] || {}
-
logistics = parsed[:logistics] || {}
-
-
fields << "scheduled_at" if interview[:scheduled_at].present?
-
fields << "duration_minutes" if interview[:duration_minutes].present?
-
fields << "stage" if interview[:stage].present?
-
fields << "interviewer_name" if interviewer[:name].present?
-
fields << "interviewer_role" if interviewer[:role].present?
-
fields << "video_link" if logistics[:video_link].present?
-
fields << "confirmation_source" if parsed[:confirmation_source].present?
-
-
fields
-
end
-
-
# Builds the extraction prompt
-
#
-
# @return [String]
-
def build_prompt
-
subject = synced_email.subject || "(No subject)"
-
body = extract_body_content
-
from_email = synced_email.from_email || ""
-
from_name = synced_email.from_name || ""
-
company_name = application.company&.name || synced_email.signal_company_name || ""
-
vars = {
-
subject: subject,
-
body: body.truncate(5000),
-
from_email: from_email,
-
from_name: from_name,
-
company_name: company_name
-
}
-
-
Ai::PromptBuilderService.new(
-
prompt_class: Ai::InterviewExtractionPrompt,
-
variables: vars
-
).run
-
end
-
-
# Extracts body content from email
-
#
-
# @return [String]
-
def extract_body_content
-
if synced_email.body_preview.present?
-
synced_email.body_preview
-
elsif synced_email.body_html.present?
-
ActionController::Base.helpers.strip_tags(synced_email.body_html)
-
else
-
synced_email.snippet || ""
-
end
-
end
-
-
# Parses LLM response JSON
-
#
-
# @param content [String] Raw LLM response
-
# @return [Hash, nil]
-
def parse_response(content)
-
parsed = Ai::ResponseParserService.new(content).parse(symbolize: true)
-
return parsed if parsed
-
-
Rails.logger.warn("[InterviewRoundProcessor] Failed to parse JSON")
-
nil
-
end
-
-
# Creates or updates interview round from extracted data
-
#
-
# @param data [Hash] Extracted interview data
-
# @param scheduled_at [DateTime, nil]
-
# @return [InterviewRound]
-
def create_or_update_round(data, scheduled_at)
-
interview_data = data[:interview] || {}
-
-
# Determine stage
-
stage = map_stage(interview_data[:stage])
-
-
# Find existing round by scheduled time (within 1 hour window)
-
existing = find_existing_round(scheduled_at, stage) if scheduled_at
-
-
if existing
-
update_existing_round(existing, data, scheduled_at)
-
else
-
create_new_round(data, scheduled_at, stage)
-
end
-
end
-
-
# Checks if extracted data indicates a true scheduling signal
-
#
-
# @param data [Hash]
-
# @param scheduled_at [DateTime, nil]
-
# @return [Boolean]
-
def scheduling_signal_present?(data, scheduled_at)
-
logistics_data = data[:logistics] || {}
-
confirmation_source = data[:confirmation_source].to_s
-
-
return true if scheduled_at.present?
-
return true if logistics_data[:video_link].present?
-
return true if data[:is_rescheduled] || data[:is_cancelled]
-
-
# Do not treat scheduling links alone as confirmation.
-
# Requests to schedule (Calendly/GoodTime links) often lack a confirmed time.
-
false
-
end
-
-
# Finds existing round by scheduled time
-
#
-
# @param scheduled_at [DateTime]
-
# @return [InterviewRound, nil]
-
def find_existing_round(scheduled_at, stage)
-
return nil unless scheduled_at
-
-
matched_by_time = application.interview_rounds
-
.where(scheduled_at: (scheduled_at - 1.hour)..(scheduled_at + 1.hour))
-
.first
-
-
return matched_by_time if matched_by_time
-
-
# Fallback: if we previously created an unscheduled round, update it
-
application.interview_rounds
-
.where(scheduled_at: nil, stage: stage.to_s)
-
.where("created_at >= ?", 14.days.ago)
-
.order(created_at: :desc)
-
.first
-
end
-
-
# Updates existing interview round
-
#
-
# @param round [InterviewRound]
-
# @param data [Hash]
-
# @return [InterviewRound]
-
def update_existing_round(round, data, scheduled_at)
-
interview_data = data[:interview] || {}
-
interviewer_data = data[:interviewer] || {}
-
logistics_data = data[:logistics] || {}
-
-
updates = {}
-
updates[:scheduled_at] = scheduled_at if scheduled_at.present? && round.scheduled_at.blank?
-
updates[:video_link] = logistics_data[:video_link] if logistics_data[:video_link].present?
-
updates[:source_email_id] = synced_email.id
-
updates[:confirmation_source] = data[:confirmation_source] if data[:confirmation_source].present?
-
updates[:interviewer_name] = interviewer_data[:name] if interviewer_data[:name].present? && round.interviewer_name.blank?
-
updates[:interviewer_role] = interviewer_data[:role] if interviewer_data[:role].present? && round.interviewer_role.blank?
-
updates[:duration_minutes] = interview_data[:duration_minutes] if interview_data[:duration_minutes].present? && round.duration_minutes.blank?
-
-
if updates.any?
-
round.update!(updates)
-
Rails.logger.info("[InterviewRoundProcessor] Updated existing round ##{round.id}")
-
end
-
round
-
end
-
-
# Creates new interview round
-
#
-
# @param data [Hash]
-
# @param scheduled_at [DateTime]
-
# @param stage [Symbol]
-
# @return [InterviewRound]
-
def create_new_round(data, scheduled_at, stage)
-
interview_data = data[:interview] || {}
-
interviewer_data = data[:interviewer] || {}
-
logistics_data = data[:logistics] || {}
-
-
# Calculate position
-
position = application.interview_rounds.maximum(:position).to_i + 1
-
-
application.interview_rounds.create!(
-
stage: stage,
-
stage_name: interview_data[:stage_name],
-
scheduled_at: scheduled_at,
-
duration_minutes: interview_data[:duration_minutes] || 30,
-
interviewer_name: interviewer_data[:name],
-
interviewer_role: interviewer_data[:role],
-
video_link: logistics_data[:video_link],
-
source_email_id: synced_email.id,
-
confirmation_source: data[:confirmation_source],
-
position: position,
-
result: :pending,
-
notes: build_round_notes(data)
-
)
-
end
-
-
# Builds notes for the interview round
-
#
-
# @param data [Hash]
-
# @return [String, nil]
-
def build_round_notes(data)
-
notes = []
-
logistics = data[:logistics] || {}
-
-
notes << "📬 Created from email signal" if synced_email.present?
-
notes << "📍 Location: #{logistics[:location]}" if logistics[:location].present?
-
notes << "📞 Phone: #{logistics[:phone_number]}" if logistics[:phone_number].present?
-
notes << "🔑 Meeting ID: #{logistics[:meeting_id]}" if logistics[:meeting_id].present?
-
notes << "🔐 Passcode: #{logistics[:passcode]}" if logistics[:passcode].present?
-
notes << "📝 #{data[:additional_instructions]}" if data[:additional_instructions].present?
-
-
notes.any? ? notes.join("\n") : nil
-
end
-
-
# Parses scheduled time from various formats
-
#
-
# @param time_str [String]
-
# @return [DateTime, nil]
-
def parse_scheduled_time(time_str)
-
return nil if time_str.blank?
-
-
DateTime.parse(time_str)
-
rescue ArgumentError, TypeError => e
-
Rails.logger.warn("[InterviewRoundProcessor] Failed to parse time '#{time_str}': #{e.message}")
-
nil
-
end
-
-
# Maps extracted stage to InterviewRound stage enum
-
#
-
# @param stage_str [String]
-
# @return [Symbol]
-
def map_stage(stage_str)
-
case stage_str&.downcase
-
when "screening" then :screening
-
when "technical" then :technical
-
when "hiring_manager" then :hiring_manager
-
when "culture_fit" then :culture_fit
-
else :other
-
end
-
end
-
-
# Returns provider chain for LLM
-
#
-
# @return [Array<String>]
-
def provider_chain
-
LlmProviders::ProviderConfigHelper.all_providers
-
end
-
-
# Gets provider instance
-
#
-
# @param provider_name [String]
-
# @return [Object, nil]
-
def get_provider_instance(provider_name)
-
case provider_name.to_s.downcase
-
when "openai" then LlmProviders::OpenaiProvider.new
-
when "anthropic" then LlmProviders::AnthropicProvider.new
-
when "ollama" then LlmProviders::OllamaProvider.new
-
else nil
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
# Service for processing round feedback emails to update interview round results
-
#
-
# Processes emails classified as round_feedback to extract pass/fail results
-
# and create InterviewFeedback records with detailed feedback.
-
#
-
# @example
-
# processor = Signals::RoundFeedbackProcessor.new(synced_email)
-
# result = processor.process
-
# if result[:success]
-
# # Round result updated and feedback created
-
# end
-
#
-
class RoundFeedbackProcessor < ApplicationService
-
attr_reader :synced_email, :application
-
-
# Email types that this processor handles
-
PROCESSABLE_TYPES = %w[round_feedback].freeze
-
-
# Minimum confidence score to accept extraction results
-
MIN_CONFIDENCE_SCORE = 0.5
-
-
# Operation type for logging
-
OPERATION_TYPE = :round_feedback_extraction
-
-
# Initialize the processor
-
#
-
# @param synced_email [SyncedEmail] The email to process
-
def initialize(synced_email)
-
@synced_email = synced_email
-
@application = synced_email.interview_application
-
end
-
-
# Processes the email to update round result and create feedback
-
#
-
# @return [Hash] Result with success status
-
def process
-
Rails.logger.info("[RoundFeedbackProcessor] Processing email ##{synced_email.id}: #{synced_email.subject}")
-
-
return skip_result("Email not matched to application") unless application
-
return skip_result("Email type not processable") unless processable?
-
return skip_result("No email content") unless content_available?
-
-
# Extract feedback data using LLM
-
extraction = extract_feedback_data
-
unless extraction[:success]
-
Rails.logger.warn("[RoundFeedbackProcessor] Extraction failed for email ##{synced_email.id}: #{extraction[:error]}")
-
return { success: false, error: extraction[:error] }
-
end
-
-
data = extraction[:data]
-
-
# Find matching round
-
round = find_matching_round(data)
-
-
if round
-
update_round_with_feedback(round, data)
-
Rails.logger.info("[RoundFeedbackProcessor] Updated round ##{round.id} result to #{round.result}")
-
{ success: true, round: round, action: :updated, llm_api_log_id: extraction[:llm_api_log_id] }
-
else
-
# Create a new round with the feedback result if we couldn't match
-
round = create_round_from_feedback(data)
-
if round&.persisted?
-
Rails.logger.info("[RoundFeedbackProcessor] Created round ##{round.id} from feedback")
-
{ success: true, round: round, action: :created, llm_api_log_id: extraction[:llm_api_log_id] }
-
else
-
Rails.logger.warn("[RoundFeedbackProcessor] Could not find or create matching round")
-
{ success: false, error: "Could not find or create matching round" }
-
end
-
end
-
rescue StandardError => e
-
notify_error(
-
e,
-
context: "round_feedback_processor",
-
user: synced_email&.user,
-
synced_email_id: synced_email&.id,
-
application_id: application&.id,
-
email_type: synced_email&.email_type,
-
company: application&.company&.name
-
)
-
Rails.logger.error("[RoundFeedbackProcessor] Error processing email ##{synced_email&.id}: #{e.message}")
-
{ success: false, error: e.message }
-
end
-
-
private
-
-
# Checks if email type is processable
-
#
-
# @return [Boolean]
-
def processable?
-
PROCESSABLE_TYPES.include?(synced_email.email_type)
-
end
-
-
# Checks if email content is available
-
#
-
# @return [Boolean]
-
def content_available?
-
synced_email.body_preview.present? ||
-
synced_email.body_html.present? ||
-
synced_email.snippet.present?
-
end
-
-
# Returns skip result
-
#
-
# @param reason [String]
-
# @return [Hash]
-
def skip_result(reason)
-
Rails.logger.info("[RoundFeedbackProcessor] Skipped email ##{synced_email&.id}: #{reason}")
-
{ success: false, skipped: true, reason: reason }
-
end
-
-
# Extracts feedback data using LLM with observability
-
#
-
# @return [Hash] Result with success and data
-
def extract_feedback_data
-
prompt = build_prompt
-
prompt_template = Ai::RoundFeedbackExtractionPrompt.active_prompt
-
system_message = prompt_template&.system_prompt.presence || Ai::RoundFeedbackExtractionPrompt.default_system_prompt
-
-
runner = Ai::ProviderRunnerService.new(
-
provider_chain: provider_chain,
-
prompt: prompt,
-
content_size: extract_body_content.bytesize,
-
system_message: system_message,
-
provider_for: method(:get_provider_instance),
-
run_options: { max_tokens: 1500, temperature: 0.1 },
-
logger_builder: lambda { |provider_name, provider|
-
Ai::ApiLoggerService.new(
-
operation_type: OPERATION_TYPE,
-
loggable: synced_email,
-
provider: provider_name,
-
model: provider.respond_to?(:model_name) ? provider.model_name : "unknown",
-
llm_prompt: prompt_template
-
)
-
},
-
operation: OPERATION_TYPE,
-
loggable: synced_email,
-
user: synced_email&.user,
-
error_context: {
-
severity: "warning",
-
synced_email_id: synced_email&.id,
-
application_id: application&.id
-
}
-
)
-
-
result = runner.run do |response|
-
parsed = parse_response(response[:content])
-
round_context = parsed[:round_context] || {}
-
feedback = parsed[:feedback] || {}
-
next_steps = parsed[:next_steps] || {}
-
-
log_data = {
-
confidence: parsed&.dig(:confidence_score),
-
result: parsed[:result],
-
sentiment: parsed[:sentiment],
-
stage_mentioned: round_context[:stage_mentioned],
-
interviewer_mentioned: round_context[:interviewer_mentioned],
-
has_detailed_feedback: feedback[:has_detailed_feedback],
-
has_next_round: next_steps[:has_next_round],
-
extracted_fields: extract_field_names(parsed)
-
}.compact
-
-
confidence_score = parsed[:confidence_score]
-
if confidence_score && confidence_score < MIN_CONFIDENCE_SCORE
-
Rails.logger.warn("[RoundFeedbackProcessor] Low confidence (#{confidence_score}) from provider")
-
end
-
accept = confidence_score.nil? || confidence_score >= MIN_CONFIDENCE_SCORE
-
[ parsed, log_data, accept ]
-
end
-
-
return { success: false, error: "Failed to extract feedback data from email" } unless result[:success]
-
-
Rails.logger.info("[RoundFeedbackProcessor] Successfully extracted with #{result[:provider]} (confidence: #{result[:parsed]&.dig(:confidence_score)}, result: #{result[:parsed]&.dig(:result)})")
-
{
-
success: true,
-
data: result[:parsed],
-
provider: result[:provider],
-
llm_api_log_id: result[:llm_api_log_id],
-
latency_ms: result[:latency_ms]
-
}
-
end
-
-
# Extracts field names that were populated
-
#
-
# @param parsed [Hash]
-
# @return [Array<String>]
-
def extract_field_names(parsed)
-
fields = []
-
round_context = parsed[:round_context] || {}
-
feedback = parsed[:feedback] || {}
-
next_steps = parsed[:next_steps] || {}
-
-
fields << "result" if parsed[:result].present?
-
fields << "sentiment" if parsed[:sentiment].present?
-
fields << "stage_mentioned" if round_context[:stage_mentioned].present?
-
fields << "interviewer_mentioned" if round_context[:interviewer_mentioned].present?
-
fields << "feedback_summary" if feedback[:summary].present?
-
fields << "strengths" if feedback[:strengths].present?
-
fields << "improvements" if feedback[:improvements].present?
-
fields << "next_round_hint" if next_steps[:next_round_hint].present?
-
-
fields
-
end
-
-
# Builds the extraction prompt
-
#
-
# @return [String]
-
def build_prompt
-
subject = synced_email.subject || "(No subject)"
-
body = extract_body_content
-
from_email = synced_email.from_email || ""
-
from_name = synced_email.from_name || ""
-
company_name = application.company&.name || synced_email.signal_company_name || ""
-
recent_rounds = build_recent_rounds_context
-
vars = {
-
subject: subject,
-
body: body.truncate(5000),
-
from_email: from_email,
-
from_name: from_name,
-
company_name: company_name,
-
recent_rounds: recent_rounds
-
}
-
-
Ai::PromptBuilderService.new(
-
prompt_class: Ai::RoundFeedbackExtractionPrompt,
-
variables: vars
-
).run
-
end
-
-
# Builds context about recent interview rounds
-
#
-
# @return [String] JSON array of recent rounds
-
def build_recent_rounds_context
-
rounds = application.interview_rounds.order(scheduled_at: :desc).limit(5)
-
-
rounds_data = rounds.map do |round|
-
{
-
id: round.id,
-
stage: round.stage,
-
stage_name: round.stage_name,
-
scheduled_at: round.scheduled_at&.iso8601,
-
interviewer_name: round.interviewer_name,
-
result: round.result
-
}
-
end
-
-
JSON.pretty_generate(rounds_data)
-
end
-
-
# Extracts body content from email
-
#
-
# @return [String]
-
def extract_body_content
-
if synced_email.body_preview.present?
-
synced_email.body_preview
-
elsif synced_email.body_html.present?
-
ActionController::Base.helpers.strip_tags(synced_email.body_html)
-
else
-
synced_email.snippet || ""
-
end
-
end
-
-
# Parses LLM response JSON
-
#
-
# @param content [String] Raw LLM response
-
# @return [Hash, nil]
-
def parse_response(content)
-
parsed = Ai::ResponseParserService.new(content).parse(symbolize: true)
-
return parsed if parsed
-
-
Rails.logger.warn("[RoundFeedbackProcessor] Failed to parse JSON")
-
nil
-
end
-
-
# Finds the matching interview round for this feedback
-
#
-
# @param data [Hash] Extracted feedback data
-
# @return [InterviewRound, nil]
-
def find_matching_round(data)
-
round_context = data[:round_context] || {}
-
-
# Strategy 1: Match by interviewer name
-
if round_context[:interviewer_mentioned].present?
-
round = application.interview_rounds
-
.where("interviewer_name ILIKE ?", "%#{round_context[:interviewer_mentioned]}%")
-
.where(result: :pending)
-
.order(scheduled_at: :desc)
-
.first
-
if round
-
Rails.logger.info("[RoundFeedbackProcessor] Matched round ##{round.id} by interviewer name")
-
return round
-
end
-
end
-
-
# Strategy 2: Match by stage/type mentioned
-
if round_context[:stage_mentioned].present?
-
stage = infer_stage_from_text(round_context[:stage_mentioned])
-
if stage
-
round = application.interview_rounds
-
.where(stage: stage, result: :pending)
-
.order(scheduled_at: :desc)
-
.first
-
if round
-
Rails.logger.info("[RoundFeedbackProcessor] Matched round ##{round.id} by stage")
-
return round
-
end
-
end
-
end
-
-
# Strategy 3: Most recent pending round
-
round = application.interview_rounds
-
.where(result: :pending)
-
.order(scheduled_at: :desc)
-
.first
-
-
Rails.logger.info("[RoundFeedbackProcessor] Matched round ##{round&.id || 'none'} by most recent pending")
-
round
-
end
-
-
# Infers stage enum from text description
-
#
-
# @param text [String]
-
# @return [Symbol, nil]
-
def infer_stage_from_text(text)
-
text_lower = text.downcase
-
-
return :screening if text_lower.match?(/screen|phone|initial|intro/)
-
return :technical if text_lower.match?(/technical|coding|system design|live coding/)
-
return :hiring_manager if text_lower.match?(/hiring manager|manager|lead/)
-
return :culture_fit if text_lower.match?(/culture|behavioral|values|team fit/)
-
-
nil
-
end
-
-
# Updates existing round with feedback
-
#
-
# @param round [InterviewRound]
-
# @param data [Hash]
-
def update_round_with_feedback(round, data)
-
# Update round result
-
result = map_result(data[:result])
-
round.update!(
-
result: result,
-
completed_at: Time.current,
-
source_email_id: synced_email.id
-
)
-
-
# Create interview feedback if detailed feedback exists
-
if data.dig(:feedback, :has_detailed_feedback)
-
create_interview_feedback(round, data)
-
end
-
end
-
-
# Creates a new round with the feedback result
-
# Used when we receive feedback but don't have a matching round
-
#
-
# @param data [Hash]
-
# @return [InterviewRound, nil]
-
def create_round_from_feedback(data)
-
existing = attach_feedback_to_latest_round(data)
-
return existing if existing
-
-
round_context = data[:round_context] || {}
-
stage = infer_stage_from_text(round_context[:stage_mentioned] || "") || :other
-
result = map_result(data[:result])
-
-
position = application.interview_rounds.maximum(:position).to_i + 1
-
-
round = application.interview_rounds.create!(
-
stage: stage,
-
stage_name: round_context[:stage_mentioned],
-
result: result,
-
completed_at: Time.current,
-
source_email_id: synced_email.id,
-
interviewer_name: round_context[:interviewer_mentioned],
-
position: position,
-
notes: "📬 Created from feedback email"
-
)
-
-
create_interview_feedback(round, data) if data.dig(:feedback, :has_detailed_feedback)
-
-
round
-
rescue ActiveRecord::RecordInvalid => e
-
Rails.logger.warn("[RoundFeedbackProcessor] Failed to create round: #{e.message}")
-
nil
-
end
-
-
# Attaches feedback to latest round when app is already rejected
-
#
-
# @param data [Hash]
-
# @return [InterviewRound, nil]
-
def attach_feedback_to_latest_round(data)
-
return nil unless application&.rejected?
-
return nil unless data.dig(:feedback, :has_detailed_feedback)
-
-
round_context = data[:round_context] || {}
-
has_matching_signal = round_context[:stage_mentioned].present? ||
-
round_context[:interviewer_mentioned].present? ||
-
round_context[:date_mentioned].present?
-
return nil if has_matching_signal
-
-
round = application.latest_round
-
return nil unless round
-
return round if round.interview_feedback.present?
-
-
create_interview_feedback(round, data)
-
round
-
end
-
-
# Creates interview feedback record
-
#
-
# @param round [InterviewRound]
-
# @param data [Hash]
-
def create_interview_feedback(round, data)
-
feedback_data = data[:feedback] || {}
-
-
# Don't create duplicate feedback
-
return if round.interview_feedback.present?
-
-
feedback = InterviewFeedback.create!(
-
interview_round: round,
-
went_well: Array(feedback_data[:strengths]).join("\n• "),
-
to_improve: Array(feedback_data[:improvements]).join("\n• "),
-
ai_summary: feedback_data[:summary],
-
interviewer_notes: feedback_data[:full_feedback_text],
-
recommended_action: determine_recommended_action(data)
-
)
-
-
Rails.logger.info("[RoundFeedbackProcessor] Created InterviewFeedback ##{feedback.id} for round ##{round.id}")
-
rescue ActiveRecord::RecordInvalid => e
-
Rails.logger.warn("[RoundFeedbackProcessor] Failed to create feedback: #{e.message}")
-
end
-
-
# Determines recommended action based on feedback
-
#
-
# @param data [Hash]
-
# @return [String, nil]
-
def determine_recommended_action(data)
-
case data[:result]
-
when "passed"
-
next_steps = data[:next_steps] || {}
-
if next_steps[:has_next_round]
-
"Prepare for #{next_steps[:next_round_type] || 'next round'}"
-
else
-
"Follow up on next steps"
-
end
-
when "failed"
-
"Review feedback and apply learnings to future interviews"
-
when "waitlisted"
-
"Follow up in 1-2 weeks if no update"
-
else
-
nil
-
end
-
end
-
-
# Maps result string to InterviewRound result enum
-
#
-
# @param result_str [String]
-
# @return [Symbol]
-
def map_result(result_str)
-
case result_str&.downcase
-
when "passed" then :passed
-
when "failed" then :failed
-
when "waitlisted" then :waitlisted
-
else :pending
-
end
-
end
-
-
# Returns provider chain for LLM
-
#
-
# @return [Array<String>]
-
def provider_chain
-
LlmProviders::ProviderConfigHelper.all_providers
-
end
-
-
# Gets provider instance
-
#
-
# @param provider_name [String]
-
# @return [Object, nil]
-
def get_provider_instance(provider_name)
-
case provider_name.to_s.downcase
-
when "openai" then LlmProviders::OpenaiProvider.new
-
when "anthropic" then LlmProviders::AnthropicProvider.new
-
when "ollama" then LlmProviders::OllamaProvider.new
-
else nil
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Rules
-
class ApplicationConfirmationRule < BaseRule
-
PRIORITY = 20
-
-
def applies?(context)
-
context.email_type == "application_confirmation"
-
end
-
-
def actions(_context)
-
[
-
{ type: :set_pipeline_stage, stage: :applied }
-
]
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Rules
-
# Base rule for signal processing.
-
class BaseRule < ApplicationService
-
DEFAULT_PRIORITY = 0
-
-
def priority
-
self.class::PRIORITY
-
rescue NameError
-
DEFAULT_PRIORITY
-
end
-
-
def safe_applies?(context)
-
applies?(context)
-
rescue StandardError => e
-
notify_error(
-
e,
-
context: "signal_rule_applies",
-
user: context.synced_email&.user,
-
synced_email_id: context.synced_email&.id,
-
application_id: context.application&.id,
-
rule: self.class.name
-
)
-
log_error("Rule #{self.class.name} applies? failed: #{e.message}")
-
false
-
end
-
-
def safe_actions(context)
-
actions(context)
-
rescue StandardError => e
-
notify_error(
-
e,
-
context: "signal_rule_actions",
-
user: context.synced_email&.user,
-
synced_email_id: context.synced_email&.id,
-
application_id: context.application&.id,
-
rule: self.class.name
-
)
-
log_error("Rule #{self.class.name} actions failed: #{e.message}")
-
[]
-
end
-
-
def applies?(_context)
-
false
-
end
-
-
def actions(_context)
-
[]
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Rules
-
class OfferRule < BaseRule
-
PRIORITY = 80
-
-
def applies?(context)
-
context.email_type == "offer"
-
end
-
-
def actions(_context)
-
[
-
{ type: :run_status_processor }
-
]
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Rules
-
class RejectionRule < BaseRule
-
PRIORITY = 100
-
-
def applies?(context)
-
context.email_type == "rejection"
-
end
-
-
def actions(_context)
-
[
-
{ type: :run_status_processor },
-
{ type: :mark_latest_round_failed }
-
]
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Rules
-
class RoundFeedbackRule < BaseRule
-
PRIORITY = 70
-
-
def applies?(context)
-
context.email_type == "round_feedback"
-
end
-
-
def actions(_context)
-
[
-
{ type: :run_round_feedback_processor },
-
{ type: :sync_application_from_round_result }
-
]
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
module Rules
-
class SchedulingRule < BaseRule
-
PRIORITY = 40
-
-
PROCESSABLE_TYPES = %w[scheduling interview_invite interview_reminder].freeze
-
-
def applies?(context)
-
PROCESSABLE_TYPES.include?(context.email_type)
-
end
-
-
def actions(_context)
-
[
-
{ type: :run_interview_round_processor },
-
{ type: :sync_pipeline_from_round_stage }
-
]
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
# Normalized context for signal rule evaluation.
-
class StateContext < ApplicationService
-
attr_reader :synced_email, :application, :email_type, :extracted_data, :latest_round, :pending_rounds
-
-
def initialize(synced_email)
-
@synced_email = synced_email
-
@application = synced_email.interview_application
-
@email_type = synced_email.email_type.to_s
-
@extracted_data = synced_email.extracted_data || {}
-
@latest_round = application&.interview_rounds&.ordered&.last
-
@pending_rounds = application ? application.interview_rounds.where(result: :pending).order(scheduled_at: :desc) : InterviewRound.none
-
end
-
-
def matched?
-
synced_email.matched?
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Signals
-
# Evaluates rules and emits ordered actions.
-
class StateTransitionPlanner < ApplicationService
-
attr_reader :context, :rules
-
-
def initialize(context, rules: default_rules)
-
@context = context
-
@rules = rules
-
end
-
-
def plan
-
applicable = rules.select { |rule| rule.safe_applies?(context) }
-
applicable.sort_by(&:priority).reverse.flat_map { |rule| rule.safe_actions(context) }
-
rescue StandardError => e
-
notify_error(
-
e,
-
context: "signal_state_planner",
-
user: context.synced_email&.user,
-
synced_email_id: context.synced_email&.id,
-
application_id: context.application&.id
-
)
-
log_error("Failed to plan actions for email #{context.synced_email&.id}: #{e.message}")
-
[]
-
end
-
-
private
-
-
def default_rules
-
[
-
Rules::RejectionRule.new,
-
Rules::OfferRule.new,
-
Rules::RoundFeedbackRule.new,
-
Rules::SchedulingRule.new,
-
Rules::ApplicationConfirmationRule.new
-
]
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module AdminSuite
-
1
class ApplicationController < ::ApplicationController
-
1
include ActionView::RecordIdentifier
-
-
# Host apps often include global auth concerns in `ApplicationController`.
-
# The engine uses `AdminSuite.config.authenticate` instead, so we defensively
-
# skip any host-level authentication before_actions that would otherwise
-
# redirect to missing routes (e.g. `new_session_path`).
-
1
skip_before_action :require_authentication, raise: false
-
-
1
before_action :admin_suite_authenticate!
-
1
layout "admin_suite/application"
-
-
1
helper AdminSuite::BaseHelper
-
1
helper_method :admin_suite_actor, :navigation_items
-
-
1
private
-
-
# Runs the host-app authentication hook (if configured).
-
#
-
# @return [void]
-
1
def admin_suite_authenticate!
-
7
hook = AdminSuite.config.authenticate
-
7
then: 0
else: 7
hook&.call(self)
-
end
-
-
# Returns the configured actor for actions/auditing/authorization.
-
#
-
# @return [Object, nil]
-
1
def admin_suite_actor
-
12
then: 12
else: 0
AdminSuite.config.current_actor&.call(self)
-
rescue StandardError
-
nil
-
end
-
-
# Loads resource definition files in development when needed.
-
#
-
# @return [void]
-
1
def ensure_resources_loaded!
-
135
else: 0
then: 135
return unless Rails.env.development?
-
then: 0
else: 0
return if Admin::Base::Resource.registered_resources.any?
-
-
Array(AdminSuite.config.resource_globs).flat_map { |g| Dir[g] }.uniq.each do |file|
-
require file
-
end
-
rescue NameError
-
# Ensure base DSL is loaded first.
-
require "admin/base/resource"
-
retry
-
end
-
-
# Loads portal definition files in development (safe to call per-request).
-
#
-
# @return [void]
-
1
def ensure_portals_loaded!
-
270
globs = Array(AdminSuite.config.portal_globs).flat_map { |g| Dir[g] }.uniq
-
135
then: 0
else: 135
return if globs.empty?
-
-
135
if Rails.env.development?
-
then: 0
# Re-evaluate definitions on each request in development.
-
AdminSuite::PortalRegistry.reset!
-
globs.each { |file| load file }
-
else
-
else: 135
# In non-dev, load once (typically at boot / first request).
-
135
then: 134
else: 1
return if AdminSuite::PortalRegistry.all.any?
-
6
globs.each { |file| require file }
-
end
-
rescue NameError
-
require "admin_suite"
-
retry
-
end
-
-
# Builds the navigation structure from registered resources.
-
#
-
# @return [Hash]
-
1
def navigation_items
-
135
ensure_resources_loaded!
-
135
ensure_portals_loaded!
-
-
135
portals = AdminSuite.config.portals
-
135
navigation = portals.each_with_object({}) do |(key, meta), h|
-
675
then: 675
else: 0
meta = meta.respond_to?(:symbolize_keys) ? meta.symbolize_keys : {}
-
675
h[key.to_sym] = meta.merge(sections: {})
-
end
-
-
# Merge any DSL-defined portal metadata into navigation.
-
135
AdminSuite::PortalRegistry.all.each do |key, definition|
-
675
navigation[key.to_sym] ||= { label: key.to_s.humanize, order: 100, sections: {} }
-
675
navigation[key.to_sym].merge!(definition.to_nav_meta)
-
675
navigation[key.to_sym][:sections] ||= {}
-
end
-
-
135
Admin::Base::Resource.registered_resources.each do |resource|
-
else: 0
then: 0
next unless resource.portal_name && resource.section_name
-
-
portal = resource.portal_name.to_sym
-
section = resource.section_name.to_sym
-
-
navigation[portal] ||= { label: portal.to_s.humanize, order: 100, sections: {} }
-
navigation[portal][:sections][section] ||= { label: section.to_s.humanize, items: [] }
-
-
label = resource.nav_label.presence || resource.human_name_plural
-
navigation[portal][:sections][section][:items] << {
-
label: label,
-
path: resources_path(portal: portal, resource_name: resource.resource_name_plural),
-
resource: resource,
-
icon: resource.nav_icon,
-
order: resource.nav_order
-
}
-
end
-
-
135
navigation
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module AdminSuite
-
1
class DashboardController < ApplicationController
-
1
def index
-
# Ensure portal/resource metadata is available.
-
3
items = navigation_items
-
-
3
@health = build_root_health
-
3
@stats = build_root_stats(items)
-
3
@recent = build_root_recent
-
-
@portal_cards =
-
18
items.sort_by { |(_k, v)| (v[:order] || 100).to_i }.map do |portal_key, portal|
-
15
color = portal[:color].presence || default_portal_color(portal_key)
-
{
-
15
key: portal_key,
-
label: portal[:label] || portal_key.to_s.humanize,
-
description: portal[:description],
-
color: color,
-
icon: portal[:icon],
-
path: portal_path(portal: portal_key),
-
count: portal[:sections].values.sum { |s| s[:items].size }
-
}
-
end
-
-
3
@dashboard_sections = build_sections
-
end
-
-
1
private
-
-
1
def default_portal_color(portal_key)
-
when: 0
case portal_key.to_sym
-
when: 0
when :ops then "amber"
-
when: 0
when :email then "emerald"
-
when: 0
when :ai then "cyan"
-
when: 0
when :assistant then "violet"
-
else: 0
when :payments then "emerald"
-
else "slate"
-
end
-
end
-
-
1
def build_sections
-
3
sections = []
-
-
3
sections << {
-
title: "System Health",
-
subtitle: nil,
-
rows: [
-
AdminSuite::UI::RowDefinition.new(panels: [
-
AdminSuite::UI::PanelDefinition.new(type: :health, title: "Application", options: { span: 3, status: @health.dig(:app, :status), metrics: @health.dig(:app, :metrics) }),
-
AdminSuite::UI::PanelDefinition.new(type: :health, title: "Scraping Pipeline", options: { span: 3, status: @health.dig(:scraping, :status), metrics: @health.dig(:scraping, :metrics) }),
-
AdminSuite::UI::PanelDefinition.new(type: :health, title: "LLM API", options: { span: 3, status: @health.dig(:llm, :status), metrics: @health.dig(:llm, :metrics) }),
-
AdminSuite::UI::PanelDefinition.new(type: :health, title: "Assistant", options: { span: 3, status: @health.dig(:assistant, :status), metrics: @health.dig(:assistant, :metrics) })
-
])
-
]
-
}
-
-
3
sections << {
-
title: nil,
-
subtitle: nil,
-
rows: [
-
AdminSuite::UI::RowDefinition.new(panels: [
-
AdminSuite::UI::PanelDefinition.new(
-
type: :cards,
-
title: "Portals",
-
options: { span: 12, variant: :portals, resources: @portal_cards }
-
)
-
])
-
]
-
}
-
-
3
sections << {
-
title: nil,
-
subtitle: nil,
-
rows: [
-
AdminSuite::UI::RowDefinition.new(panels: [
-
AdminSuite::UI::PanelDefinition.new(type: :stat, title: "Total Resources", options: { span: 3, variant: :mini, color: :slate, value: @stats[:total_resources] }),
-
AdminSuite::UI::PanelDefinition.new(type: :stat, title: "Ops Resources", options: { span: 3, variant: :mini, color: :amber, value: @stats[:ops_resources] }),
-
AdminSuite::UI::PanelDefinition.new(type: :stat, title: "Email Resources", options: { span: 2, variant: :mini, color: :emerald, value: @stats[:email_resources] }),
-
AdminSuite::UI::PanelDefinition.new(type: :stat, title: "AI Resources", options: { span: 2, variant: :mini, color: :cyan, value: @stats[:ai_resources] }),
-
AdminSuite::UI::PanelDefinition.new(type: :stat, title: "Assistant Resources", options: { span: 2, variant: :mini, color: :violet, value: @stats[:assistant_resources] })
-
])
-
]
-
}
-
-
3
sections << {
-
title: "Recent Activity",
-
subtitle: nil,
-
rows: [
-
AdminSuite::UI::RowDefinition.new(panels: [
-
3
AdminSuite::UI::PanelDefinition.new(type: :recent, title: "Recent Signups", options: { span: 3, scope: @recent[:recent_users], view_all_path: ->(view) { view.resources_path(portal: :ops, resource_name: "users") } }),
-
3
AdminSuite::UI::PanelDefinition.new(type: :recent, title: "Recent Applications", options: { span: 3, scope: @recent[:recent_applications], view_all_path: ->(view) { view.resources_path(portal: :ops, resource_name: "interview_applications") } }),
-
3
AdminSuite::UI::PanelDefinition.new(type: :recent, title: "Recent Assistant", options: { span: 3, scope: @recent[:recent_threads], view_all_path: ->(view) { view.resources_path(portal: :assistant, resource_name: "assistant_threads") } }),
-
3
AdminSuite::UI::PanelDefinition.new(type: :recent, title: "Recent Scraping", options: { span: 3, scope: @recent[:recent_scraping], view_all_path: ->(view) { view.resources_path(portal: :ops, resource_name: "scraping_attempts") } })
-
])
-
]
-
}
-
-
3
sections
-
end
-
-
1
def build_root_stats(items)
-
{
-
3
total_resources: Admin::Base::Resource.registered_resources.count,
-
portals: items.keys.count,
-
ops_resources: Admin::Base::Resource.resources_for_portal(:ops).count,
-
email_resources: Admin::Base::Resource.resources_for_portal(:email).count,
-
ai_resources: Admin::Base::Resource.resources_for_portal(:ai).count,
-
assistant_resources: Admin::Base::Resource.resources_for_portal(:assistant).count
-
}
-
rescue StandardError
-
{ total_resources: 0, portals: 0, ops_resources: 0, email_resources: 0, ai_resources: 0, assistant_resources: 0 }
-
end
-
-
1
def build_root_recent
-
{
-
6
then: 3
else: 0
recent_users: -> { defined?(::User) ? ::User.order(created_at: :desc).limit(5) : [] },
-
3
then: 3
else: 0
recent_applications: -> { defined?(::InterviewApplication) ? ::InterviewApplication.order(created_at: :desc).limit(5) : [] },
-
3
then: 3
else: 0
recent_threads: -> { defined?(::Assistant::ChatThread) ? ::Assistant::ChatThread.order(created_at: :desc).limit(5) : [] },
-
3
then: 3
else: 0
recent_scraping: -> { defined?(::ScrapingAttempt) ? ::ScrapingAttempt.order(created_at: :desc).limit(5) : [] }
-
}
-
end
-
-
1
def build_root_health
-
{
-
3
app: app_health,
-
scraping: scraping_health,
-
llm: llm_health,
-
assistant: assistant_health
-
}
-
end
-
-
1
def app_health
-
3
else: 3
then: 0
return { status: :unknown, metrics: {} } unless defined?(::User)
-
-
metrics = {
-
3
"Users" => safe_count(::User),
-
3
"24h signups" => safe_count(::User, ->(rel) { rel.where("created_at > ?", 24.hours.ago) }),
-
3
then: 3
else: 0
"Applications" => (defined?(::InterviewApplication) ? safe_count(::InterviewApplication) : "—"),
-
3
then: 3
else: 0
"Job listings" => (defined?(::JobListing) ? safe_count(::JobListing) : "—")
-
}
-
-
3
{ status: :healthy, metrics: metrics }
-
rescue StandardError
-
{ status: :unknown, metrics: {} }
-
end
-
-
1
def scraping_health
-
3
else: 3
then: 0
return { status: :unknown, metrics: {} } unless defined?(::ScrapingAttempt)
-
-
3
recent_attempts = ::ScrapingAttempt.where("created_at > ?", 24.hours.ago)
-
3
total = recent_attempts.count
-
3
successful = recent_attempts.where(status: :completed).count
-
3
failed = recent_attempts.where(status: :failed).count
-
3
stuck = recent_attempts.where(status: :processing).where("updated_at < ?", 1.hour.ago).count
-
-
3
then: 0
else: 3
success_rate = total > 0 ? (successful.to_f / total * 100).round : 0
-
status =
-
3
then: 0
if stuck > 5 || (total > 10 && success_rate < 50)
-
else: 3
:critical
-
3
then: 0
elsif stuck > 0 || (total > 10 && success_rate < 80)
-
:degraded
-
else: 3
else
-
3
:healthy
-
end
-
-
{
-
3
status: status,
-
metrics: {
-
"24h attempts" => total,
-
"success rate" => "#{success_rate}%",
-
"failed" => failed,
-
"stuck" => stuck
-
}
-
}
-
rescue StandardError
-
{ status: :unknown, metrics: {} }
-
end
-
-
1
def llm_health
-
3
else: 3
then: 0
return { status: :unknown, metrics: {} } unless defined?(::Ai::LlmApiLog)
-
-
3
recent_logs = ::Ai::LlmApiLog.where("created_at > ?", 24.hours.ago)
-
3
total = recent_logs.count
-
3
successful = recent_logs.where(status: :success).count
-
3
failed = recent_logs.where(status: :failed).count
-
3
then: 0
else: 3
avg_latency = recent_logs.where(status: :success).average(:latency_ms)&.round || 0
-
3
total_cost_cents = recent_logs.sum(:estimated_cost_cents) || 0
-
3
total_cost = (total_cost_cents / 100.0).round(2)
-
-
3
then: 0
else: 3
success_rate = total > 0 ? (successful.to_f / total * 100).round : 0
-
status =
-
3
then: 0
if total > 10 && success_rate < 80
-
else: 3
:critical
-
3
then: 0
elsif total > 10 && success_rate < 95
-
:degraded
-
else: 3
else
-
3
:healthy
-
end
-
-
{
-
3
status: status,
-
metrics: {
-
"24h calls" => total,
-
"success rate" => "#{success_rate}%",
-
"avg latency" => "#{avg_latency}ms",
-
"24h cost" => "$#{total_cost}",
-
"failed" => failed
-
}
-
}
-
rescue StandardError
-
{ status: :unknown, metrics: {} }
-
end
-
-
1
def assistant_health
-
3
else: 3
then: 0
return { status: :unknown, metrics: {} } unless defined?(::Assistant::ToolExecution)
-
-
3
then: 3
else: 0
recent_threads = (defined?(::Assistant::ChatThread) ? ::Assistant::ChatThread.where("created_at > ?", 24.hours.ago) : nil)
-
3
recent_executions = ::Assistant::ToolExecution.where("created_at > ?", 24.hours.ago)
-
-
3
total_executions = recent_executions.count
-
3
successful = recent_executions.where(status: :completed).count
-
3
failed = recent_executions.where(status: :failed).count
-
3
pending = ::Assistant::ToolExecution.where(status: :pending_approval).count
-
-
3
then: 0
else: 3
success_rate = total_executions > 0 ? (successful.to_f / total_executions * 100).round : 100
-
status =
-
3
then: 0
if failed > 10
-
else: 3
:critical
-
3
then: 0
elsif pending > 20 || (total_executions > 10 && success_rate < 70)
-
:degraded
-
else: 3
else
-
3
:healthy
-
end
-
-
{
-
3
status: status,
-
metrics: {
-
3
then: 3
else: 0
"24h threads" => (recent_threads ? recent_threads.count : "—"),
-
"24h tool runs" => total_executions,
-
"success rate" => "#{success_rate}%",
-
"pending" => pending
-
}
-
}
-
rescue StandardError
-
{ status: :unknown, metrics: {} }
-
end
-
-
1
def safe_count(klass, scope_proc = nil)
-
12
rel = klass.all
-
12
then: 3
else: 9
rel = scope_proc.call(rel) if scope_proc
-
12
rel.count
-
rescue StandardError
-
"—"
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module AdminSuite
-
1
class DocsController < ApplicationController
-
1
before_action :set_docs_root
-
-
# GET /docs
-
# GET /docs?path=relative/path.md
-
1
def index
-
2
@files = grouped_markdown_files
-
2
@selected_path = params[:path].presence
-
-
2
then: 0
if @selected_path.present?
-
else: 2
load_doc_content(@selected_path)
-
2
then: 2
else: 0
elsif @files.values.flatten.any?
-
2
@selected_path = @files.values.flatten.first
-
2
load_doc_content(@selected_path)
-
end
-
end
-
-
# GET /docs/*path
-
1
def show
-
2
relative_path = params[:path].to_s
-
2
then: 0
else: 2
if params[:format].present? && !relative_path.end_with?(".#{params[:format]}")
-
relative_path = "#{relative_path}.#{params[:format]}"
-
end
-
-
# Even when the doc path ends with `.md`, we always render HTML.
-
2
request.format = :html
-
2
file_path = resolve_doc_path!(relative_path)
-
-
1
@files = grouped_markdown_files
-
1
@selected_path = relative_path
-
1
@title = File.basename(file_path, ".md").tr("_", " ").tr("-", " ").titleize
-
1
@raw_markdown = File.read(file_path)
-
-
1
rendered = markdown_renderer.new(@raw_markdown).render
-
1
@content_html = rendered[:html]
-
1
@toc = rendered[:toc]
-
1
@reading_time = rendered[:reading_time_minutes]
-
-
1
render :index, formats: [:html]
-
rescue ActiveRecord::RecordNotFound
-
1
redirect_to docs_path, alert: "Doc not found."
-
end
-
-
1
private
-
-
1
def set_docs_root
-
4
@docs_root = docs_root
-
end
-
-
1
def load_doc_content(relative_path)
-
2
file_path = resolve_doc_path!(relative_path)
-
2
@title = File.basename(file_path, ".md").tr("_", " ").tr("-", " ").titleize
-
2
@raw_markdown = File.read(file_path)
-
-
2
rendered = markdown_renderer.new(@raw_markdown).render
-
2
@content_html = rendered[:html]
-
2
@toc = rendered[:toc]
-
2
@reading_time = rendered[:reading_time_minutes]
-
rescue ActiveRecord::RecordNotFound
-
@title = nil
-
@content_html = nil
-
@toc = []
-
@reading_time = nil
-
end
-
-
1
def markdown_renderer
-
3
AdminSuite::MarkdownRenderer
-
rescue NameError
-
# In development, new engine lib files can be added without a server restart.
-
# Make the docs viewer resilient by loading the renderer on demand.
-
require "admin_suite/markdown_renderer"
-
AdminSuite::MarkdownRenderer
-
end
-
-
1
def grouped_markdown_files
-
3
base = docs_root_realpath
-
3
files = Dir.glob(base.join("**/*.md")).sort.map do |abs|
-
120
abs_path = Pathname.new(abs)
-
120
abs_path.relative_path_from(base).to_s
-
end
-
-
123
groups = files.group_by { |path| group_name_for_path(path) }
-
42
groups.sort_by { |k, _| k.to_s }.to_h
-
rescue StandardError
-
{}
-
end
-
-
1
def group_name_for_path(relative_path)
-
120
folder = relative_path.to_s.split(File::SEPARATOR).first
-
120
then: 114
else: 6
if folder.present? && folder != File.basename(relative_path.to_s)
-
114
return humanize_folder_name(folder)
-
end
-
-
6
"Docs"
-
end
-
-
1
def humanize_folder_name(folder)
-
114
normalized = folder.to_s.tr("_", " ").tr("-", " ").strip
-
114
acronyms = {
-
"cicd" => "CICD",
-
"ci cd" => "CICD",
-
"ai" => "AI",
-
"ops" => "Ops",
-
"oauth" => "OAuth",
-
"ui" => "UI",
-
"ux" => "UX",
-
"api" => "API"
-
}
-
-
114
key = normalized.downcase
-
114
then: 18
else: 96
return acronyms[key] if acronyms.key?(key)
-
-
96
normalized.titleize
-
end
-
-
1
def docs_root
-
value =
-
10
then: 10
if AdminSuite.config.respond_to?(:docs_path)
-
10
AdminSuite.config.docs_path
-
else: 0
else
-
Rails.root.join("docs")
-
end
-
10
then: 3
else: 7
value = value.call(self) if value.respond_to?(:call)
-
10
then: 0
else: 10
value = Rails.root.join("docs") if value.blank?
-
10
Pathname.new(value.to_s)
-
end
-
-
1
def resolve_doc_path!(relative_path)
-
4
then: 0
else: 4
raise ActiveRecord::RecordNotFound if relative_path.blank?
-
4
then: 1
else: 3
raise ActiveRecord::RecordNotFound if relative_path.include?("..")
-
-
3
base = docs_root_realpath
-
3
candidate = base.join(relative_path)
-
3
else: 3
then: 0
raise ActiveRecord::RecordNotFound unless candidate.extname == ".md"
-
-
3
real = candidate.realpath
-
3
else: 3
then: 0
raise ActiveRecord::RecordNotFound unless real.to_s.start_with?(base.to_s + File::SEPARATOR)
-
-
3
real.to_s
-
rescue Errno::ENOENT, Errno::EACCES
-
raise ActiveRecord::RecordNotFound
-
end
-
-
1
def docs_root_realpath
-
6
docs_root.realpath
-
rescue Errno::ENOENT
-
docs_root
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module AdminSuite
-
# Helper methods for the Admin Suite engine UI.
-
#
-
# This is intentionally very close to the `/internal/developer` helper so we can
-
# keep both UIs side-by-side and compare behavior while migrating.
-
1
module BaseHelper
-
1
include Pagy::Frontend
-
1
include AdminSuite::IconHelper
-
1
include AdminSuite::PanelsHelper
-
1
include AdminSuite::ThemeHelper
-
1
then: 1
else: 0
include ::Internal::Developer::CustomRenderersHelper if defined?(::Internal::Developer::CustomRenderersHelper)
-
# ActiveStorage route helpers live on the host app (main_app), not the isolated engine.
-
1
def admin_suite_rails_blob_path(...)
-
then: 0
if respond_to?(:main_app) && main_app.respond_to?(:rails_blob_path)
-
main_app.rails_blob_path(...)
-
else: 0
else
-
rails_blob_path(...)
-
end
-
end
-
-
1
def admin_suite_rails_blob_representation_path(...)
-
then: 0
if respond_to?(:main_app) && main_app.respond_to?(:rails_blob_representation_path)
-
main_app.rails_blob_representation_path(...)
-
else: 0
else
-
rails_blob_representation_path(...)
-
end
-
end
-
-
# Lookup the DSL field definition for a given attribute (if present).
-
#
-
# Used to render show values with type awareness (e.g. markdown/json/label).
-
1
def admin_suite_field_definition(field_name)
-
else: 0
then: 0
return nil unless respond_to?(:resource_config, true)
-
-
rc = resource_config
-
else: 0
then: 0
return nil unless rc
-
-
then: 0
else: 0
rc.form_config&.fields_list.to_a.find do |f|
-
f.respond_to?(:name) &&
-
f.respond_to?(:type) &&
-
f.name.to_sym == field_name.to_sym
-
end
-
rescue StandardError
-
nil
-
end
-
-
-
# Prefer registry-driven implementations (with legacy fallbacks via `super`).
-
1
prepend AdminSuite::UI::ShowValueFormatter
-
1
prepend AdminSuite::UI::FormFieldRenderer
-
-
# Returns the color scheme for a portal
-
#
-
# @param portal_key [Symbol] Portal identifier
-
# @return [String]
-
1
def portal_color(portal_key)
-
60
portal_key = portal_key.to_sym
-
60
color = (navigation_items.dig(portal_key, :color) rescue nil)
-
60
then: 60
else: 0
return color.to_s if color.present?
-
-
when: 0
case portal_key
-
when: 0
when :ops then "amber"
-
when: 0
when :ai then "cyan"
-
when: 0
when :assistant then "violet"
-
else: 0
when :email then "emerald"
-
else "slate"
-
end
-
end
-
-
# Returns an icon for a portal.
-
#
-
# @param portal_key [Symbol] Portal identifier
-
# @return [ActiveSupport::SafeBuffer, String]
-
1
def portal_icon(portal_key, **opts)
-
60
portal_key = portal_key.to_sym
-
60
icon = (navigation_items.dig(portal_key, :icon) rescue nil)
-
60
icon ||= begin
-
{
-
ops: "settings",
-
ai: "sparkles",
-
assistant: "bot",
-
email: "mail"
-
}[portal_key]
-
end
-
60
icon = icon.presence || "layout-grid"
-
-
60
admin_suite_icon(icon, **opts)
-
end
-
-
# Renders a column value from a record
-
#
-
# @param record [ActiveRecord::Base] The record
-
# @param column [Admin::Base::Resource::ColumnDefinition] Column definition
-
# @return [String]
-
1
def render_column_value(record, column)
-
then: 0
if column.type == :toggle
-
field = (column.toggle_field || column.name).to_sym
-
render partial: "admin_suite/shared/toggle_cell",
-
else: 0
locals: { record: record, field: field }
-
then: 0
elsif column.type == :label
-
then: 0
else: 0
value = column.content.is_a?(Proc) ? column.content.call(record) : (record.public_send(column.name) rescue nil)
-
else: 0
render_label_badge(value, color: column.label_color, size: column.label_size, record: record)
-
then: 0
elsif column.content.is_a?(Proc)
-
column.content.call(record)
-
else: 0
else
-
record.public_send(column.name) rescue "—"
-
end
-
end
-
-
# Formats a value for display on show pages
-
#
-
# @param record [ActiveRecord::Base] The record
-
# @param field_name [Symbol, String] Field name
-
# @return [String] HTML safe formatted value
-
1
def format_show_value(record, field_name)
-
value = record.public_send(field_name) rescue nil
-
-
then: 0
if value.is_a?(ActiveStorage::Attached::One)
-
else: 0
return render_attachment_preview(value)
-
then: 0
else: 0
elsif value.is_a?(ActiveStorage::Attached::Many)
-
return render_attachments_preview(value)
-
end
-
-
case value
-
when: 0
when nil
-
content_tag(:span, "—", class: "text-slate-400")
-
when: 0
when true
-
content_tag(:span, class: "inline-flex items-center gap-1") do
-
svg = '<svg class="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>'.html_safe
-
concat(svg)
-
concat(content_tag(:span, "Yes", class: "text-green-600 dark:text-green-400 font-medium"))
-
end
-
when: 0
when false
-
content_tag(:span, class: "inline-flex items-center gap-1") do
-
svg = '<svg class="w-4 h-4 text-slate-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>'.html_safe
-
concat(svg)
-
concat(content_tag(:span, "No", class: "text-slate-500"))
-
end
-
when: 0
when Time, DateTime
-
content_tag(:span, class: "inline-flex items-center gap-2") do
-
concat(content_tag(:span, value.strftime("%B %d, %Y at %H:%M"), class: "font-medium"))
-
concat(content_tag(:span, "(#{time_ago_in_words(value)} ago)", class: "text-slate-500 dark:text-slate-400 text-xs"))
-
end
-
when: 0
when Date
-
value.strftime("%B %d, %Y")
-
when: 0
when ActiveRecord::Base
-
then: 0
else: 0
link_text = value.respond_to?(:name) ? value.name : "#{value.class.name} ##{value.id}"
-
content_tag(:span, link_text, class: "text-indigo-600 dark:text-indigo-400")
-
when: 0
when Hash
-
render_json_block(value)
-
when: 0
when Array
-
then: 0
if value.empty?
-
else: 0
content_tag(:span, "Empty array", class: "text-slate-400 italic")
-
then: 0
elsif value.first.is_a?(Hash)
-
render_json_block(value)
-
else: 0
else
-
content_tag(:div, class: "flex flex-wrap gap-1") do
-
value.each do |item|
-
concat(content_tag(:span, item.to_s, class: "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300"))
-
end
-
end
-
end
-
when: 0
when Integer, Float, BigDecimal
-
content_tag(:span, number_with_delimiter(value), class: "font-mono")
-
else: 0
else
-
value_str = value.to_s
-
-
then: 0
if value_str.start_with?("{", "[") && value_str.length > 10
-
begin
-
parsed = JSON.parse(value_str)
-
render_json_block(parsed)
-
rescue JSON::ParserError
-
render_text_block(value_str)
-
else: 0
end
-
then: 0
elsif value_str.include?("\n") || value_str.length > 200
-
render_text_block(value_str, detect_language(field_name, value_str))
-
else: 0
else
-
value_str
-
end
-
end
-
end
-
-
1
def render_attachment_preview(attachment)
-
else: 0
then: 0
return content_tag(:span, "—", class: "text-slate-400") unless attachment.attached?
-
-
blob = attachment.blob
-
-
then: 0
if blob.image?
-
variant = attachment.variant(resize_to_limit: [ 600, 400 ])
-
variant_url =
-
begin
-
admin_suite_rails_blob_representation_path(variant.processed, only_path: true)
-
rescue StandardError
-
admin_suite_rails_blob_path(blob, disposition: :inline)
-
end
-
-
content_tag(:div, class: "space-y-2") do
-
concat(content_tag(:div, class: "inline-block rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700") do
-
image_tag(variant_url,
-
class: "max-w-full h-auto max-h-64 object-contain",
-
alt: blob.filename.to_s)
-
end)
-
concat(content_tag(:div, class: "flex items-center gap-3 text-sm text-slate-500 dark:text-slate-400") do
-
concat(content_tag(:span, blob.filename.to_s, class: "font-medium text-slate-700 dark:text-slate-300"))
-
concat(content_tag(:span, "•"))
-
concat(content_tag(:span, number_to_human_size(blob.byte_size)))
-
concat(content_tag(:span, "•"))
-
concat(link_to("View full size", admin_suite_rails_blob_path(blob, disposition: :inline), target: "_blank", class: "text-indigo-600 dark:text-indigo-400 hover:underline"))
-
end)
-
end
-
else: 0
else
-
content_tag(:div, class: "flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700") do
-
concat(content_tag(:div, class: "flex-shrink-0 w-10 h-10 bg-slate-200 dark:bg-slate-700 rounded-lg flex items-center justify-center") do
-
'<svg class="w-5 h-5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>'.html_safe
-
end)
-
concat(content_tag(:div, class: "flex-1 min-w-0") do
-
concat(content_tag(:p, blob.filename.to_s, class: "font-medium text-slate-700 dark:text-slate-300 truncate"))
-
concat(content_tag(:p, number_to_human_size(blob.byte_size), class: "text-sm text-slate-500 dark:text-slate-400"))
-
end)
-
concat(link_to("Download", admin_suite_rails_blob_path(blob, disposition: :attachment),
-
class: "flex-shrink-0 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-lg transition-colors"))
-
end
-
end
-
end
-
-
1
def render_attachments_preview(attachments)
-
else: 0
then: 0
return content_tag(:span, "—", class: "text-slate-400") unless attachments.attached?
-
-
content_tag(:div, class: "grid grid-cols-2 md:grid-cols-3 gap-4") do
-
attachments.each do |attachment|
-
concat(render_attachment_preview(attachment))
-
end
-
end
-
end
-
-
1
def render_json_block(data)
-
json_str = JSON.pretty_generate(data)
-
-
content_tag(:div, class: "relative group") do
-
concat(content_tag(:div, class: "absolute top-2 right-2 flex items-center gap-2") do
-
concat(content_tag(:span, "JSON", class: "text-xs font-medium text-slate-400 dark:text-slate-500 uppercase tracking-wider"))
-
concat(content_tag(:button,
-
'<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>'.html_safe,
-
type: "button",
-
class: "p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 opacity-0 group-hover:opacity-100 transition-opacity",
-
data: { controller: "admin-suite--clipboard", action: "click->admin-suite--clipboard#copy", "admin-suite--clipboard-text-value": json_str },
-
title: "Copy to clipboard"))
-
end)
-
-
concat(content_tag(:pre, class: "bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm font-mono max-h-96 overflow-y-auto") do
-
content_tag(:code, class: "language-json") do
-
highlight_json(json_str)
-
end
-
end)
-
end
-
end
-
-
1
def render_text_block(text, language = nil)
-
content_tag(:div, class: "relative group") do
-
concat(content_tag(:div, class: "absolute top-2 right-2 flex items-center gap-2") do
-
then: 0
else: 0
concat(content_tag(:span, language.to_s.upcase, class: "text-xs font-medium text-slate-400 dark:text-slate-500 uppercase tracking-wider")) if language
-
concat(content_tag(:button,
-
'<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>'.html_safe,
-
type: "button",
-
class: "p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 opacity-0 group-hover:opacity-100 transition-opacity",
-
data: { controller: "admin-suite--clipboard", action: "click->admin-suite--clipboard#copy", "admin-suite--clipboard-text-value": text },
-
title: "Copy to clipboard"))
-
end)
-
-
concat(content_tag(:pre, class: "bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm font-mono max-h-96 overflow-y-auto whitespace-pre-wrap") do
-
then: 0
else: 0
content_tag(:code, h(text), class: language ? "language-#{language}" : nil)
-
end)
-
end
-
end
-
-
1
def highlight_json(json_str)
-
highlighted = h(json_str)
-
.gsub(/("(?:[^"\\]|\\.)*")(\s*:)/) { "<span class=\"text-purple-400\">#{$1}</span>#{$2}" }
-
.gsub(/:\s*("(?:[^"\\]|\\.)*")/) { ":<span class=\"text-green-400\">#{$1}</span>" }
-
.gsub(/:\s*(true|false)/) { ":<span class=\"text-orange-400\">#{$1}</span>" }
-
.gsub(/:\s*(-?\d+(?:\.\d+)?)/) { ":<span class=\"text-cyan-400\">#{$1}</span>" }
-
.gsub(/:\s*(null)/) { ":<span class=\"text-red-400\">#{$1}</span>" }
-
-
highlighted.html_safe
-
end
-
-
1
def detect_language(field_name, content)
-
field_str = field_name.to_s.downcase
-
-
then: 0
else: 0
return :markdown if field_str.include?("template") || field_str.include?("prompt")
-
then: 0
else: 0
return :ruby if field_str.include?("code") && content.include?("def ")
-
then: 0
else: 0
return :sql if field_str.include?("query") || field_str.include?("sql")
-
then: 0
else: 0
return :html if field_str.include?("html") || field_str.include?("body")
-
-
then: 0
else: 0
return :json if content.strip.start_with?("{", "[")
-
then: 0
else: 0
return :ruby if content.include?("def ") || content.include?("class ")
-
then: 0
else: 0
return :sql if content.upcase.include?("SELECT ") || content.upcase.include?("INSERT ")
-
then: 0
else: 0
return :html if content.include?("<html") || content.include?("<div")
-
-
nil
-
end
-
-
1
def render_custom_section(resource, render_type)
-
renderer = AdminSuite.config.custom_renderers[render_type.to_sym] rescue nil
-
then: 0
else: 0
return renderer.call(resource, self) if renderer
-
-
case render_type.to_sym
-
when: 0
when :prompt_template_preview
-
render_prompt_template(resource)
-
when: 0
when :json_preview
-
render_json_preview(resource)
-
when: 0
when :code_preview
-
render_code_preview(resource)
-
when: 0
when :messages_preview
-
render_messages_preview(resource)
-
when: 0
when :tool_args_preview
-
render_tool_args_preview(resource)
-
when: 0
when :turn_messages_preview
-
render_turn_messages_preview(resource)
-
else: 0
else
-
content_tag(:p, "Unknown render type: #{render_type}", class: "text-slate-500 italic")
-
end
-
end
-
-
# --- generic custom renderers (fallbacks) ---
-
1
def render_prompt_template(resource)
-
then: 0
else: 0
template = resource.respond_to?(:prompt_template) ? resource.prompt_template : nil
-
then: 0
else: 0
return content_tag(:p, "No template defined", class: "text-slate-500 italic") if template.blank?
-
-
highlighted_template = h(template).gsub(/\{\{(\w+)\}\}/) do
-
"<span class=\"text-amber-400 bg-amber-900/30 px-1 rounded\">{{#{$1}}}</span>"
-
end
-
-
content_tag(:div, class: "relative group") do
-
concat(content_tag(:div, class: "absolute top-2 right-2 flex items-center gap-2") do
-
concat(content_tag(:span, "TEMPLATE", class: "text-xs font-medium text-slate-400 dark:text-slate-500 uppercase tracking-wider"))
-
concat(content_tag(:button,
-
'<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>'.html_safe,
-
type: "button",
-
class: "p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 opacity-0 group-hover:opacity-100 transition-opacity",
-
data: { controller: "admin-suite--clipboard", action: "click->admin-suite--clipboard#copy", "admin-suite--clipboard-text-value": template },
-
title: "Copy to clipboard"))
-
end)
-
-
concat(content_tag(:pre, class: "bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm font-mono max-h-[600px] overflow-y-auto whitespace-pre-wrap leading-relaxed") do
-
highlighted_template.html_safe
-
end)
-
-
variables = template.scan(/\{\{(\w+)\}\}/).flatten.uniq
-
then: 0
else: 0
if variables.any?
-
concat(content_tag(:div, class: "mt-3 pt-3 border-t border-slate-700") do
-
concat(content_tag(:span, "Variables: ", class: "text-sm text-slate-400"))
-
concat(content_tag(:div, class: "inline-flex flex-wrap gap-1 mt-1") do
-
variables.each do |var|
-
concat(content_tag(:code, "{{#{var}}}", class: "text-xs px-2 py-0.5 bg-amber-900/30 text-amber-400 rounded"))
-
end
-
end)
-
end)
-
end
-
end
-
end
-
-
1
def render_json_preview(resource)
-
then: 0
else: 0
data = resource.respond_to?(:data) ? resource.data : resource.attributes
-
render_json_block(data)
-
end
-
-
1
def render_code_preview(resource)
-
then: 0
else: 0
code = resource.respond_to?(:code) ? resource.code : resource.to_s
-
render_text_block(code, :ruby)
-
end
-
-
1
def render_messages_preview(resource)
-
then: 0
else: 0
messages = resource.respond_to?(:messages) ? resource.messages : []
-
then: 0
else: 0
if messages.respond_to?(:chronological)
-
messages = messages.chronological
-
end
-
then: 0
else: 0
messages = messages.limit(50) if messages.respond_to?(:limit)
-
messages = Array.wrap(messages)
-
-
then: 0
else: 0
return content_tag(:p, "No messages", class: "text-slate-500 italic") if messages.blank?
-
-
content_tag(:div, class: "space-y-4 max-h-[600px] overflow-y-auto -mx-6 -mb-6 p-6 pt-0") do
-
messages.each_with_index do |msg, idx|
-
then: 0
if msg.respond_to?(:role)
-
role = msg.role
-
content = msg.content
-
then: 0
else: 0
created_at = msg.respond_to?(:created_at) ? msg.created_at : nil
-
else: 0
else
-
role = msg["role"] || msg[:role] || "unknown"
-
content = msg["content"] || msg[:content] || ""
-
created_at = msg["created_at"] || msg[:created_at]
-
end
-
-
when: 0
role_class = case role.to_s
-
when: 0
when "user" then "bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800"
-
when: 0
when "assistant" then "bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800"
-
when: 0
when "tool" then "bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800"
-
else: 0
when "system" then "bg-slate-50 dark:bg-slate-700/50 border-slate-200 dark:border-slate-600"
-
else "bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700"
-
end
-
-
role_icon = case role.to_s
-
when: 0
when "user"
-
'<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>'.html_safe
-
when: 0
when "assistant"
-
'<svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/></svg>'.html_safe
-
when: 0
when "tool"
-
'<svg class="w-4 h-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/></svg>'.html_safe
-
else: 0
else
-
'<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>'.html_safe
-
end
-
-
concat(content_tag(:div, class: "rounded-lg border p-4 #{role_class}") do
-
concat(content_tag(:div, class: "flex items-center justify-between mb-3") do
-
concat(content_tag(:div, class: "flex items-center gap-2") do
-
concat(role_icon)
-
concat(content_tag(:span, role.to_s.capitalize, class: "text-sm font-medium text-slate-700 dark:text-slate-200"))
-
end)
-
concat(content_tag(:div, class: "flex items-center gap-2 text-xs text-slate-400") do
-
then: 0
else: 0
concat(content_tag(:span, created_at.strftime("%H:%M:%S"))) if created_at.respond_to?(:strftime)
-
concat(content_tag(:span, "##{idx + 1}"))
-
end)
-
end)
-
-
content_str = content.to_s
-
then: 0
if role.to_s == "tool" && content_str.start_with?("{", "[")
-
begin
-
parsed = JSON.parse(content_str)
-
concat(render_json_block(parsed))
-
rescue JSON::ParserError
-
concat(content_tag(:div, simple_format(h(content_str)), class: "prose dark:prose-invert prose-sm max-w-none"))
-
end
-
else: 0
else
-
concat(content_tag(:div, simple_format(h(content_str)), class: "prose dark:prose-invert prose-sm max-w-none"))
-
end
-
end)
-
end
-
end
-
end
-
-
1
def render_tool_args_preview(resource)
-
then: 0
else: 0
then: 0
else: 0
args = resource.respond_to?(:args) ? resource.args : (resource.respond_to?(:arguments) ? resource.arguments : {})
-
then: 0
else: 0
result = resource.respond_to?(:result) ? resource.result : nil
-
then: 0
else: 0
error = resource.respond_to?(:error) ? resource.error : nil
-
-
content_tag(:div, class: "space-y-6") do
-
concat(content_tag(:div) do
-
concat(content_tag(:h4, "Arguments", class: "text-sm font-medium text-slate-500 dark:text-slate-400 mb-2"))
-
then: 0
if args.present? && args != {}
-
concat(render_json_block(args))
-
else: 0
else
-
concat(content_tag(:p, "No arguments", class: "text-slate-400 italic text-sm"))
-
end
-
end)
-
-
then: 0
else: 0
if result.present? && result != {}
-
concat(content_tag(:div, class: "pt-4 border-t border-slate-200 dark:border-slate-700") do
-
concat(content_tag(:h4, "Result", class: "text-sm font-medium text-slate-500 dark:text-slate-400 mb-2"))
-
concat(render_json_block(result))
-
end)
-
end
-
-
then: 0
else: 0
if error.present?
-
concat(content_tag(:div, class: "pt-4 border-t border-slate-200 dark:border-slate-700") do
-
concat(content_tag(:h4, "Error", class: "text-sm font-medium text-red-500 dark:text-red-400 mb-2"))
-
concat(content_tag(:div, class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4") do
-
content_tag(:pre, h(error.to_s), class: "text-sm text-red-700 dark:text-red-300 whitespace-pre-wrap font-mono")
-
end)
-
end)
-
end
-
end
-
end
-
-
1
def render_turn_messages_preview(resource)
-
then: 0
else: 0
user_msg = resource.respond_to?(:user_message) ? resource.user_message : nil
-
then: 0
else: 0
asst_msg = resource.respond_to?(:assistant_message) ? resource.assistant_message : nil
-
-
content_tag(:div, class: "space-y-4") do
-
then: 0
else: 0
if user_msg
-
concat(content_tag(:div, class: "rounded-lg border p-4 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800") do
-
concat(content_tag(:div, class: "flex items-center gap-2 mb-2") do
-
concat('<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>'.html_safe)
-
concat(content_tag(:span, "User", class: "text-sm font-medium text-slate-700 dark:text-slate-200"))
-
end)
-
then: 0
else: 0
concat(content_tag(:div, simple_format(h(user_msg.respond_to?(:content) ? user_msg.content.to_s : user_msg.to_s)), class: "prose dark:prose-invert prose-sm max-w-none"))
-
end)
-
end
-
-
then: 0
else: 0
if asst_msg
-
concat(content_tag(:div, class: "rounded-lg border p-4 bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800") do
-
concat(content_tag(:div, class: "flex items-center gap-2 mb-2") do
-
concat('<svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/></svg>'.html_safe)
-
concat(content_tag(:span, "Assistant", class: "text-sm font-medium text-slate-700 dark:text-slate-200"))
-
end)
-
then: 0
else: 0
concat(content_tag(:div, simple_format(h(asst_msg.respond_to?(:content) ? asst_msg.content.to_s : asst_msg.to_s)), class: "prose dark:prose-invert prose-sm max-w-none"))
-
end)
-
end
-
-
else: 0
then: 0
concat(content_tag(:p, "No messages found", class: "text-slate-400 italic text-sm")) unless user_msg || asst_msg
-
end
-
end
-
-
1
def auto_admin_suite_path_for(item)
-
else: 0
then: 0
return nil unless item.is_a?(ActiveRecord::Base)
-
-
ensure_admin_resources_loaded_for!(item.class)
-
-
resource = Admin::Base::Resource.registered_resources.find { |r| r.model_class == item.class }
-
then: 0
else: 0
else: 0
then: 0
return nil unless resource&.portal_name && resource.respond_to?(:resource_name_plural)
-
-
resource_path(portal: resource.portal_name, resource_name: resource.resource_name_plural, id: item.to_param)
-
rescue StandardError
-
nil
-
end
-
-
1
def ensure_admin_resources_loaded_for!(model_class)
-
already_loaded = Admin::Base::Resource.registered_resources.any? { |r| r.model_class == model_class }
-
then: 0
else: 0
return if already_loaded
-
-
Array(AdminSuite.config.resource_globs).flat_map { |g| Dir[g] }.uniq.each do |file|
-
require file
-
end
-
rescue NameError
-
require "admin/base/resource"
-
retry
-
end
-
-
# ---- show page sections / associations ----
-
#
-
# For parity, we keep the same section rendering and association displays used by
-
# `/internal/developer`. This is intentionally "UI heavy".
-
-
1
def render_show_section(resource, section, position = :main)
-
is_association = section.association.present? && !resource.public_send(section.association).is_a?(ActiveRecord::Base) rescue false
-
-
content_tag(:div, class: "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden") do
-
then: 0
else: 0
header_padding = position == :sidebar ? "px-4 py-2.5" : "px-6 py-3"
-
then: 0
else: 0
header_text_size = position == :sidebar ? "text-sm" : ""
-
then: 0
else: 0
header_border = is_association ? "" : "border-b border-slate-200 dark:border-slate-700"
-
-
concat(content_tag(:div, class: "#{header_padding} #{header_border} bg-slate-50 dark:bg-slate-900/50 flex items-center justify-between") do
-
concat(content_tag(:h3, section.title, class: "font-medium text-slate-900 dark:text-white #{header_text_size}"))
-
-
then: 0
else: 0
if section.association.present?
-
assoc = resource.public_send(section.association) rescue nil
-
then: 0
else: 0
if assoc && !assoc.is_a?(ActiveRecord::Base)
-
count = assoc.count rescue 0
-
then: 0
else: 0
color_class = count > 0 ? "bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400" : "bg-slate-200 dark:bg-slate-600 text-slate-600 dark:text-slate-300"
-
concat(content_tag(:span, number_with_delimiter(count), class: "text-xs font-semibold px-2 py-0.5 rounded-full #{color_class}"))
-
end
-
end
-
end)
-
-
then: 0
else: 0
content_padding = position == :sidebar ? "p-4" : "p-6"
-
then: 0
else: 0
if is_association && position == :main
-
then: 0
else: 0
content_padding = section.paginate ? "pt-0 px-6 pb-0" : "pt-0 px-6 pb-6"
-
end
-
then: 0
else: 0
content_padding = "pt-0 p-4" if is_association && position == :sidebar
-
-
concat(content_tag(:div, class: content_padding) do
-
then: 0
if section.render.present?
-
else: 0
render_custom_section(resource, section.render)
-
then: 0
elsif section.association.present?
-
else: 0
render_association_section(resource, section)
-
then: 0
elsif section.fields.any?
-
then: 0
else: 0
position == :sidebar ? render_sidebar_fields(resource, section.fields) : render_main_fields(resource, section.fields)
-
else: 0
else
-
content_tag(:p, "No content", class: "text-slate-400 italic text-sm")
-
end
-
end)
-
end
-
end
-
-
1
def render_sidebar_fields(resource, fields)
-
content_tag(:div, class: "space-y-3") do
-
fields.each do |field_name|
-
value = resource.public_send(field_name) rescue nil
-
then: 0
if value.is_a?(ActiveStorage::Attached::One) || value.is_a?(ActiveStorage::Attached::Many)
-
concat(render_sidebar_attachment(value))
-
else: 0
else
-
concat(content_tag(:div, class: "flex justify-between items-start gap-2") do
-
concat(content_tag(:span, field_name.to_s.humanize, class: "text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider flex-shrink-0"))
-
concat(content_tag(:span, class: "text-sm text-slate-900 dark:text-white text-right") { format_show_value(resource, field_name) })
-
end)
-
end
-
end
-
end
-
end
-
-
1
def render_sidebar_attachment(attachment)
-
else: 0
then: 0
return content_tag(:div, class: "text-center py-4") { content_tag(:span, "No image", class: "text-slate-400 text-sm") } unless attachment.respond_to?(:attached?) && attachment.attached?
-
-
then: 0
else: 0
single = attachment.is_a?(ActiveStorage::Attached::Many) ? attachment.first : attachment
-
blob = single.blob
-
then: 0
if blob.image?
-
variant = single.variant(resize_to_limit: [ 400, 300 ])
-
variant_url =
-
begin
-
admin_suite_rails_blob_representation_path(variant.processed, only_path: true)
-
rescue StandardError
-
admin_suite_rails_blob_path(blob, disposition: :inline)
-
end
-
-
content_tag(:div, class: "space-y-2") do
-
concat(content_tag(:div, class: "rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700") do
-
image_tag(variant_url, class: "w-full h-auto object-cover", alt: blob.filename.to_s)
-
end)
-
concat(content_tag(:div, class: "flex items-center justify-between text-xs text-slate-500 dark:text-slate-400") do
-
concat(content_tag(:span, number_to_human_size(blob.byte_size)))
-
concat(link_to("View full", admin_suite_rails_blob_path(blob, disposition: :inline), target: "_blank", class: "text-indigo-600 dark:text-indigo-400 hover:underline"))
-
end)
-
end
-
else: 0
else
-
content_tag(:div, class: "flex items-center gap-2 p-2 bg-slate-50 dark:bg-slate-800 rounded-lg") do
-
concat(content_tag(:div, class: "flex-shrink-0 w-8 h-8 bg-slate-200 dark:bg-slate-700 rounded flex items-center justify-center") do
-
'<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>'.html_safe
-
end)
-
concat(content_tag(:div, class: "flex-1 min-w-0") do
-
concat(content_tag(:p, blob.filename.to_s.truncate(20), class: "text-xs font-medium text-slate-700 dark:text-slate-300 truncate"))
-
concat(content_tag(:p, number_to_human_size(blob.byte_size), class: "text-xs text-slate-500"))
-
end)
-
end
-
end
-
end
-
-
1
def render_main_fields(resource, fields)
-
content_tag(:dl, class: "space-y-6") do
-
fields.each do |field_name|
-
concat(content_tag(:div) do
-
concat(content_tag(:dt, field_name.to_s.humanize, class: "text-sm font-medium text-slate-500 dark:text-slate-400 mb-2"))
-
concat(content_tag(:dd, class: "text-sm text-slate-900 dark:text-white") { format_show_value(resource, field_name) })
-
end)
-
end
-
end
-
end
-
-
# ---- association rendering ----
-
1
def render_association_section(resource, section)
-
associated = resource.public_send(section.association) rescue nil
-
then: 0
else: 0
return content_tag(:p, "None found", class: "text-slate-400 italic text-sm") if associated.nil?
-
-
is_single = !associated.respond_to?(:to_a) || associated.is_a?(ActiveRecord::Base)
-
then: 0
else: 0
return render_association_card_single(associated, section) if is_single
-
-
items = associated
-
pagy = nil
-
-
then: 0
if section.paginate
-
per_page = (section.per_page || section.limit || 20).to_i
-
then: 0
else: 0
per_page = 1 if per_page < 1
-
page_param = association_page_param(section)
-
page = params[page_param].presence || 1
-
then: 0
else: 0
total_count = associated.respond_to?(:count) ? associated.count : associated.to_a.size
-
pagy = Pagy.new(count: total_count, page: page, limit: per_page, page_param: page_param)
-
else: 0
then: 0
else: 0
items = associated.respond_to?(:offset) ? associated.offset(pagy.offset).limit(per_page) : Array.wrap(associated)[pagy.offset, per_page] || []
-
then: 0
else: 0
elsif section.limit
-
then: 0
else: 0
items = associated.respond_to?(:limit) ? associated.limit(section.limit) : Array.wrap(associated).first(section.limit)
-
end
-
-
items = Array.wrap(items)
-
then: 0
else: 0
return content_tag(:p, "None found", class: "text-slate-400 italic text-sm") if items.empty?
-
-
content_tag(:div) do
-
case section.display
-
when: 0
when :table
-
concat(render_association_table(items, section))
-
when: 0
when :cards
-
concat(render_association_cards(items, section))
-
else: 0
else
-
concat(render_association_list(items, section))
-
end
-
then: 0
else: 0
concat(render_association_pagination(pagy)) if pagy
-
end
-
end
-
-
1
def association_page_param(section) = "#{section.association}_page"
-
-
1
def render_association_pagination(pagy)
-
content_tag(:div, class: "-mx-6 border-t border-slate-200 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-900/30 px-6 py-3") do
-
content_tag(:nav, class: "flex items-center justify-between", "aria-label" => "Pagination") do
-
concat(pagy_prev_link(pagy))
-
concat(pagy_page_links(pagy))
-
concat(pagy_next_link(pagy))
-
end
-
end
-
end
-
-
1
def pagy_prev_link(pagy)
-
then: 0
if pagy.prev
-
link_to("Prev", pagy_url_for(pagy, pagy.prev),
-
class: "px-3 py-1.5 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors")
-
else: 0
else
-
content_tag(:span, "Prev",
-
class: "px-3 py-1.5 text-sm font-medium text-slate-400 dark:text-slate-500 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg cursor-not-allowed")
-
end
-
end
-
-
1
def pagy_next_link(pagy)
-
then: 0
if pagy.next
-
link_to("Next", pagy_url_for(pagy, pagy.next),
-
class: "px-3 py-1.5 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors")
-
else: 0
else
-
content_tag(:span, "Next",
-
class: "px-3 py-1.5 text-sm font-medium text-slate-400 dark:text-slate-500 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg cursor-not-allowed")
-
end
-
end
-
-
1
def pagy_page_links(pagy)
-
content_tag(:div, class: "flex items-center gap-1") do
-
pagy.series.each { |item| concat(render_pagy_series_item(pagy, item)) }
-
end
-
end
-
-
1
def render_pagy_series_item(pagy, item)
-
case item
-
when: 0
when Integer
-
link_to(item, pagy_url_for(pagy, item),
-
class: "px-2.5 py-1 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors")
-
when: 0
when String
-
content_tag(:span, item, class: "px-2.5 py-1 text-sm font-semibold text-white bg-indigo-600 border border-indigo-600 rounded")
-
when: 0
when :gap
-
content_tag(:span, "…", class: "px-2 text-sm text-slate-400 dark:text-slate-500")
-
else: 0
else
-
""
-
end
-
end
-
-
1
def render_association_card_single(item, section)
-
link_path = build_association_link(item, section)
-
-
card_content = capture do
-
concat(content_tag(:div, class: "flex items-center justify-between gap-3") do
-
concat(content_tag(:div, class: "min-w-0 flex-1") do
-
title = item_display_title(item)
-
then: 0
else: 0
title_class = link_path ? "font-medium text-slate-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400" : "font-medium text-slate-900 dark:text-white"
-
concat(content_tag(:div, title, class: title_class))
-
-
subtitle = []
-
then: 0
else: 0
subtitle << item.status.to_s.humanize if item.respond_to?(:status) && item.status.present?
-
then: 0
else: 0
subtitle << item.email_address if item.respond_to?(:email_address) && item.email_address.present?
-
then: 0
else: 0
subtitle << item.tool_key if item.respond_to?(:tool_key) && item.tool_key.present?
-
then: 0
else: 0
concat(content_tag(:div, subtitle.first, class: "text-sm text-slate-500 dark:text-slate-400 mt-0.5")) if subtitle.any?
-
end)
-
-
then: 0
else: 0
if link_path
-
concat('<svg class="w-5 h-5 text-slate-300 dark:text-slate-600 group-hover:text-indigo-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>'.html_safe)
-
end
-
end)
-
end
-
-
then: 0
else: 0
link_path ? link_to(card_content, link_path, class: "flex items-center -m-4 p-4 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/10 transition-colors group") : content_tag(:div, card_content, class: "flex items-center")
-
end
-
-
1
def render_association_list(items, section)
-
content_tag(:div, class: "divide-y divide-slate-200 dark:divide-slate-700 -mx-6 -mt-2 -mb-6") do
-
items.each do |item|
-
link_path = build_association_link(item, section)
-
then: 0
wrapper = if link_path
-
->(content) { link_to(link_path, class: "block px-6 py-4 hover:bg-indigo-50/50 dark:hover:bg-indigo-900/10 transition-colors group") { content } }
-
else: 0
else
-
->(content) { content_tag(:div, content, class: "px-6 py-4") }
-
end
-
-
concat(wrapper.call(capture do
-
concat(content_tag(:div, class: "flex items-start justify-between gap-4") do
-
concat(content_tag(:div, class: "min-w-0 flex-1") do
-
concat(content_tag(:div, class: "flex items-center gap-2") do
-
title = item_display_title(item)
-
then: 0
else: 0
title_class = link_path ? "text-slate-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400" : "text-slate-900 dark:text-white"
-
concat(content_tag(:span, title.truncate(60), class: "font-medium #{title_class} truncate"))
-
then: 0
else: 0
concat(render_status_badge(item.status, size: :sm)) if item.respond_to?(:status) && item.status.present?
-
end)
-
end)
-
-
concat(content_tag(:div, class: "flex items-center gap-3 flex-shrink-0 text-xs text-slate-400") do
-
then: 0
else: 0
concat(content_tag(:span, time_ago_in_words(item.created_at) + " ago")) if item.respond_to?(:created_at) && item.created_at
-
then: 0
else: 0
if link_path
-
concat('<svg class="w-4 h-4 text-slate-300 dark:text-slate-600 group-hover:text-indigo-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>'.html_safe)
-
end
-
end)
-
end)
-
end))
-
end
-
end
-
end
-
-
# Minimal association table support (matches internal portal table UX enough for now).
-
1
def render_association_table(items, section)
-
columns = section.columns.presence || detect_table_columns(items.first)
-
-
content_tag(:div, class: "overflow-x-auto -mx-6 -mt-1") do
-
content_tag(:table, class: "min-w-full divide-y divide-slate-200 dark:divide-slate-700") do
-
concat(content_tag(:thead, class: "bg-slate-50/50 dark:bg-slate-900/30") do
-
content_tag(:tr) do
-
Array.wrap(columns).each do |col|
-
header = col.to_s.gsub(/_id$/, "").humanize
-
concat(content_tag(:th, header, class: "px-4 py-2.5 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider first:pl-6"))
-
end
-
concat(content_tag(:th, "", class: "px-4 py-2.5 w-16"))
-
end
-
end)
-
-
concat(content_tag(:tbody, class: "divide-y divide-slate-200 dark:divide-slate-700") do
-
items.each do |item|
-
link_path = build_association_link(item, section)
-
then: 0
else: 0
concat(content_tag(:tr, class: link_path ? "hover:bg-indigo-50/50 dark:hover:bg-indigo-900/10 cursor-pointer group" : "") do
-
Array.wrap(columns).each_with_index do |col, idx|
-
value = item.public_send(col) rescue nil
-
text = format_table_cell(value)
-
then: 0
else: 0
concat(content_tag(:td, text, class: (idx == 0 ? "px-4 py-3 text-sm first:pl-6" : "px-4 py-3 text-sm")))
-
end
-
concat(content_tag(:td, class: "px-4 py-3 text-right pr-6") do
-
then: 0
else: 0
link_path ? link_to("View", link_path, class: "inline-flex items-center text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 text-sm font-medium") : ""
-
end)
-
end)
-
end
-
end)
-
end
-
end
-
end
-
-
1
def render_association_cards(items, section)
-
content_tag(:div, class: "grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1") do
-
items.each do |item|
-
link_path = build_association_link(item, section)
-
card_class = "border border-slate-200 dark:border-slate-700 rounded-lg p-4 transition-all"
-
then: 0
else: 0
card_class += link_path ? " hover:border-indigo-300 dark:hover:border-indigo-700 hover:shadow-md group cursor-pointer" : " hover:bg-slate-50 dark:hover:bg-slate-900/30"
-
-
card_content = capture do
-
concat(content_tag(:div, class: "flex items-start justify-between gap-2 mb-2") do
-
title = item_display_title(item)
-
then: 0
else: 0
title_class = link_path ? "font-medium text-slate-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400" : "font-medium text-slate-900 dark:text-white"
-
concat(content_tag(:span, title.truncate(35), class: title_class))
-
then: 0
else: 0
concat(render_status_badge(item.status, size: :sm)) if item.respond_to?(:status) && item.status.present?
-
end)
-
concat(content_tag(:div, class: "flex items-center justify-between text-xs text-slate-400 pt-2 border-t border-slate-100 dark:border-slate-700/50") do
-
then: 0
else: 0
concat(content_tag(:span, time_ago_in_words(item.created_at) + " ago")) if item.respond_to?(:created_at) && item.created_at
-
then: 0
else: 0
concat('<svg class="w-4 h-4 text-slate-300 dark:text-slate-600 group-hover:text-indigo-500 group-hover:translate-x-0.5 transition-all" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>'.html_safe) if link_path
-
end)
-
end
-
-
then: 0
else: 0
concat(link_path ? link_to(card_content, link_path, class: card_class) : content_tag(:div, card_content, class: card_class))
-
end
-
end
-
end
-
-
1
def detect_table_columns(item)
-
else: 0
then: 0
return [ :id, :name, :created_at ] unless item
-
priority = [ :name, :title, :status ]
-
attrs = item.attributes.keys.map(&:to_sym)
-
selected = priority.select { |c| attrs.include?(c) }
-
then: 0
else: 0
selected << :created_at if selected.size < 5 && attrs.include?(:created_at)
-
selected.take(5)
-
end
-
-
1
def format_table_cell(value)
-
when: 0
case value
-
when: 0
when nil then "—"
-
when: 0
then: 0
else: 0
when true, false then value ? "Yes" : "No"
-
when: 0
when Time, DateTime then value.strftime("%b %d, %H:%M")
-
when: 0
when Date then value.strftime("%b %d, %Y")
-
else: 0
when ActiveRecord::Base then item_display_title(value)
-
else value.to_s.truncate(50)
-
end
-
end
-
-
1
def item_display_title(item)
-
then: 0
else: 0
return item.name if item.respond_to?(:name) && item.name.present?
-
then: 0
else: 0
return item.title if item.respond_to?(:title) && item.title.present?
-
then: 0
else: 0
return item.display_title if item.respond_to?(:display_title) && item.display_title.present?
-
then: 0
else: 0
return item.content.to_s.truncate(50) if item.respond_to?(:content)
-
-
"##{item.id}"
-
end
-
-
1
def build_association_link(item, section)
-
then: 0
else: 0
if section.link_to.present?
-
begin
-
return send(section.link_to, item)
-
rescue NoMethodError
-
# fall through to auto-link
-
end
-
end
-
-
auto_admin_suite_path_for(item)
-
end
-
-
1
def render_status_badge(status, size: :md)
-
then: 0
else: 0
return content_tag(:span, "—", class: "text-slate-400") if status.blank?
-
-
status_str = status.to_s.downcase
-
colors = case status_str
-
when: 0
when "active", "open", "success", "approved", "completed", "enabled"
-
"bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"
-
when: 0
when "pending", "proposed", "queued", "waiting"
-
"bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400"
-
when: 0
when "running", "processing", "in_progress"
-
"bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400"
-
when: 0
when "error", "failed", "rejected", "cancelled"
-
"bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400"
-
else: 0
else
-
"bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400"
-
end
-
-
then: 0
else: 0
padding = size == :sm ? "px-1.5 py-0.5 text-xs" : "px-2 py-1 text-xs"
-
content_tag(:span, status_str.titleize, class: "inline-flex items-center #{padding} rounded-full font-medium #{colors}")
-
end
-
-
1
def render_label_badge(value, color: nil, size: :md, record: nil)
-
then: 0
else: 0
return content_tag(:span, "—", class: "text-slate-400") if value.blank?
-
-
label_color = resolve_label_option(color, record).presence || :slate
-
label_size = resolve_label_option(size, record).presence || :md
-
colors = label_badge_colors(label_color)
-
then: 0
else: 0
padding = label_size.to_s == "sm" ? "px-1.5 py-0.5 text-xs" : "px-2 py-1 text-xs"
-
content_tag(:span, value.to_s, class: "inline-flex items-center #{padding} rounded-md font-medium #{colors}")
-
end
-
-
1
def resolve_label_option(option, record)
-
then: 0
else: 0
return option.call(record) if option.is_a?(Proc)
-
option
-
end
-
-
1
def label_badge_colors(color)
-
case color.to_s.downcase
-
when: 0
when "green"
-
"bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"
-
when: 0
when "amber", "yellow", "orange"
-
"bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400"
-
when: 0
when "blue"
-
"bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400"
-
when: 0
when "red"
-
"bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400"
-
when: 0
when "indigo"
-
"bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400"
-
when: 0
when "purple"
-
"bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400"
-
when: 0
when "violet"
-
"bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-400"
-
when: 0
when "emerald"
-
"bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400"
-
when: 0
when "cyan"
-
"bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-400"
-
else: 0
else
-
"bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400"
-
end
-
end
-
-
# ---- form fields ----
-
1
def render_form_field(f, field, resource)
-
then: 0
else: 0
return if field.if_condition.present? && !field.if_condition.call(resource)
-
then: 0
else: 0
return if field.unless_condition.present? && field.unless_condition.call(resource)
-
-
capture do
-
concat(content_tag(:div, class: "form-group") do
-
concat(f.label(field.name, class: "form-label") do
-
concat(field.label)
-
then: 0
else: 0
concat(content_tag(:span, " *", class: "text-red-500")) if field.required
-
end)
-
-
field_class = "form-input w-full"
-
then: 0
else: 0
field_class += " border-red-500" if resource.errors[field.name].any?
-
-
when: 0
field_html = case field.type
-
when: 0
when :textarea then f.text_area(field.name, class: field_class, rows: field.rows || 4, placeholder: field.placeholder, readonly: field.readonly)
-
when: 0
when :url then f.url_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
-
when: 0
when :email then f.email_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
-
when: 0
when :number then f.number_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
-
when :toggle then render_toggle_field(f, field, resource)
-
when: 0
when :label
-
label_value = resource.public_send(field.name) rescue nil
-
render_label_badge(label_value, color: field.label_color, size: field.label_size, record: resource)
-
when: 0
when :select
-
then: 0
else: 0
collection = field.collection.is_a?(Proc) ? field.collection.call : field.collection
-
when: 0
f.select(field.name, collection, { include_blank: true }, class: field_class, disabled: field.readonly)
-
when: 0
when :searchable_select then render_searchable_select(f, field, resource)
-
when: 0
when :multi_select, :tags then render_multi_select(f, field, resource)
-
when: 0
when :image, :attachment then render_file_upload(f, field, resource)
-
when :trix, :rich_text then f.rich_text_area(field.name, class: "prose dark:prose-invert max-w-none")
-
when: 0
when :markdown
-
when: 0
f.text_area(field.name, class: "#{field_class} font-mono", rows: field.rows || 12, data: { controller: "admin-suite--markdown-editor" }, placeholder: field.placeholder)
-
when: 0
when :file then f.file_field(field.name, class: "form-input-file", accept: field.accept)
-
when: 0
when :datetime then f.datetime_local_field(field.name, class: field_class, readonly: field.readonly)
-
when: 0
when :date then f.date_field(field.name, class: field_class, readonly: field.readonly)
-
when :time then f.time_field(field.name, class: field_class, readonly: field.readonly)
-
when: 0
when :json
-
when: 0
render("admin_suite/shared/json_editor_field", f: f, field: field, resource: resource)
-
when :code then render_code_editor(f, field, resource)
-
else: 0
else
-
f.text_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
-
end
-
-
concat(field_html)
-
-
then: 0
else: 0
concat(content_tag(:p, field.help, class: "mt-1 text-sm text-slate-500 dark:text-slate-400")) if field.help.present?
-
then: 0
else: 0
concat(content_tag(:p, resource.errors[field.name].first, class: "mt-1 text-sm text-red-600 dark:text-red-400")) if resource.errors[field.name].any?
-
end)
-
end
-
end
-
-
1
def render_toggle_field(_f, field, resource)
-
checked = !!resource.public_send(field.name)
-
param_key = resource.class.model_name.param_key
-
-
content_tag(:div,
-
class: "inline-flex items-center gap-3",
-
data: {
-
controller: "admin-suite--toggle-switch",
-
"admin-suite--toggle-switch-active-class-value": "is-on",
-
"admin-suite--toggle-switch-inactive-classes-value": ""
-
}) do
-
concat(content_tag(:button, type: "button",
-
then: 0
else: 0
class: "admin-suite-toggle-track #{checked ? "is-on" : ""}",
-
role: "switch",
-
"aria-checked" => checked.to_s,
-
data: { action: "click->admin-suite--toggle-switch#toggle", "admin-suite--toggle-switch-target": "button" },
-
disabled: field.readonly) do
-
content_tag(:span, "", class: "admin-suite-toggle-thumb", data: { "admin-suite--toggle-switch-target": "thumb" })
-
end)
-
-
then: 0
else: 0
concat(hidden_field_tag("#{param_key}[#{field.name}]", checked ? "1" : "0", id: "#{param_key}_#{field.name}", data: { "admin-suite--toggle-switch-target": "input" }))
-
then: 0
else: 0
concat(content_tag(:span, checked ? "Enabled" : "Disabled", class: "text-sm font-medium text-slate-700", data: { "admin-suite--toggle-switch-target": "label" }))
-
end
-
end
-
-
1
def render_searchable_select(_f, field, resource)
-
param_key = resource.class.model_name.param_key
-
current_value = resource.public_send(field.name)
-
then: 0
else: 0
collection = field.collection.is_a?(Proc) ? field.collection.call : field.collection
-
-
then: 0
options_json = if collection.is_a?(Array)
-
then: 0
else: 0
collection.map { |opt| opt.is_a?(Array) ? { value: opt[1], label: opt[0] } : { value: opt, label: opt.to_s.humanize } }.to_json
-
else: 0
else
-
"[]"
-
end
-
-
then: 0
current_label = if current_value.present? && collection.is_a?(Array)
-
then: 0
else: 0
match = collection.find { |opt| opt.is_a?(Array) ? opt[1].to_s == current_value.to_s : opt.to_s == current_value.to_s }
-
then: 0
else: 0
match.is_a?(Array) ? match[0] : match.to_s
-
else: 0
else
-
current_value
-
end
-
-
content_tag(:div,
-
data: {
-
controller: "admin-suite--searchable-select",
-
"admin-suite--searchable-select-options-value": options_json,
-
"admin-suite--searchable-select-creatable-value": field.create_url.present?,
-
then: 0
else: 0
"admin-suite--searchable-select-search-url-value": collection.is_a?(String) ? collection : ""
-
},
-
class: "relative") do
-
concat(hidden_field_tag("#{param_key}[#{field.name}]", current_value, data: { "admin-suite--searchable-select-target": "input" }))
-
concat(text_field_tag(nil, current_label,
-
class: "form-input w-full",
-
placeholder: field.placeholder || "Search...",
-
autocomplete: "off",
-
data: {
-
"admin-suite--searchable-select-target": "search",
-
action: "input->admin-suite--searchable-select#search focus->admin-suite--searchable-select#open keydown->admin-suite--searchable-select#keydown"
-
}))
-
concat(content_tag(:div, "",
-
class: "absolute z-10 w-full mt-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg hidden max-h-60 overflow-y-auto",
-
data: { "admin-suite--searchable-select-target": "dropdown" }))
-
end
-
end
-
-
1
def render_multi_select(_f, field, resource)
-
param_key = resource.class.model_name.param_key
-
current_values =
-
then: 0
if resource.respond_to?("#{field.name}_list")
-
else: 0
resource.public_send("#{field.name}_list")
-
then: 0
elsif resource.respond_to?(field.name)
-
Array.wrap(resource.public_send(field.name))
-
else: 0
else
-
[]
-
end
-
-
options =
-
then: 0
if field.collection.is_a?(Proc)
-
else: 0
field.collection.call
-
then: 0
elsif field.collection.is_a?(Array)
-
field.collection
-
else: 0
else
-
[]
-
end
-
-
then: 0
else: 0
field_name = field.type == :tags ? "tag_list" : field.name
-
full_field_name = "#{param_key}[#{field_name}][]"
-
-
content_tag(:div,
-
data: {
-
controller: "admin-suite--tag-select",
-
"admin-suite--tag-select-creatable-value": field.create_url.present? || field.type == :tags,
-
"admin-suite--tag-select-field-name-value": full_field_name
-
},
-
class: "space-y-2") do
-
concat(hidden_field_tag(full_field_name, "", id: nil, data: { "admin-suite--tag-select-target": "placeholder" }))
-
-
concat(content_tag(:div,
-
class: "flex flex-wrap gap-2 min-h-[2.5rem] p-2 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg",
-
data: { "admin-suite--tag-select-target": "tags" }) do
-
current_values.each do |val|
-
concat(content_tag(:span,
-
class: "inline-flex items-center gap-1 px-2 py-1 bg-indigo-100 dark:bg-indigo-900/50 text-indigo-700 dark:text-indigo-300 rounded text-sm") do
-
concat(val.to_s)
-
concat(hidden_field_tag(full_field_name, val, id: nil))
-
concat(button_tag("×", type: "button", class: "text-indigo-500 hover:text-indigo-700 font-bold", data: { action: "admin-suite--tag-select#remove" }))
-
end)
-
end
-
concat(text_field_tag(nil, "",
-
class: "flex-1 min-w-[120px] border-none focus:outline-none focus:ring-0 bg-transparent text-sm",
-
placeholder: field.placeholder || "Add tag...",
-
autocomplete: "off",
-
data: { "admin-suite--tag-select-target": "input", action: "keydown->admin-suite--tag-select#keydown input->admin-suite--tag-select#search" }))
-
end)
-
-
then: 0
else: 0
if options.any?
-
concat(content_tag(:div,
-
class: "hidden border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 shadow-lg max-h-48 overflow-y-auto",
-
data: { "admin-suite--tag-select-target": "dropdown" }) do
-
options.each do |opt|
-
then: 0
else: 0
label, value = opt.is_a?(Array) ? [ opt[0], opt[1] ] : [ opt, opt ]
-
concat(content_tag(:button, label,
-
type: "button",
-
class: "block w-full text-left px-3 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-700",
-
data: { action: "admin-suite--tag-select#select", value: value }))
-
end
-
end)
-
end
-
end
-
end
-
-
1
def render_file_upload(f, field, resource)
-
then: 0
else: 0
attachment = resource.respond_to?(field.name) ? resource.public_send(field.name) : nil
-
has_attachment = attachment.respond_to?(:attached?) && attachment.attached?
-
is_image = field.type == :image || (field.accept.present? && field.accept.include?("image"))
-
existing_url =
-
then: 0
else: 0
if has_attachment && is_image
-
variant = attachment.variant(resize_to_limit: [ 300, 300 ])
-
begin
-
admin_suite_rails_blob_representation_path(variant.processed, only_path: true)
-
rescue StandardError
-
admin_suite_rails_blob_path(attachment.blob, disposition: :inline)
-
end
-
end
-
-
content_tag(:div,
-
data: {
-
controller: "admin-suite--file-upload",
-
then: 0
else: 0
"admin-suite--file-upload-accept-value": field.accept || (is_image ? "image/*" : "*/*"),
-
"admin-suite--file-upload-preview-value": field.type == :image,
-
"admin-suite--file-upload-existing-url-value": existing_url
-
},
-
class: "space-y-3") do
-
then: 0
if has_attachment && is_image
-
concat(content_tag(:div, class: "relative inline-block") do
-
concat(image_tag(existing_url, class: "max-w-[200px] max-h-[150px] rounded-lg border border-slate-200 dark:border-slate-700 object-cover", data: { "admin-suite--file-upload-target": "imagePreview" }))
-
concat(button_tag("×", type: "button",
-
class: "absolute -top-2 -right-2 w-6 h-6 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center text-sm",
-
data: { "admin-suite--file-upload-target": "removeButton", action: "admin-suite--file-upload#remove" }))
-
end)
-
else: 0
else
-
concat(image_tag("", class: "hidden max-w-[200px] max-h-[150px] rounded-lg border border-slate-200 dark:border-slate-700 object-cover", data: { "admin-suite--file-upload-target": "imagePreview" }))
-
concat(content_tag(:div, "", class: "hidden", data: { "admin-suite--file-upload-target": "filename" }))
-
end
-
-
concat(content_tag(:div,
-
class: "relative border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg hover:border-indigo-400 dark:hover:border-indigo-500 transition-colors",
-
data: { "admin-suite--file-upload-target": "dropzone" }) do
-
concat(f.file_field(field.name,
-
class: "sr-only",
-
id: "#{field.name}_input",
-
then: 0
else: 0
accept: field.accept || (is_image ? "image/*" : nil),
-
data: { "admin-suite--file-upload-target": "input", action: "change->admin-suite--file-upload#preview" }))
-
-
concat(content_tag(:label, for: "#{field.name}_input",
-
class: "flex flex-col items-center justify-center w-full py-6 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-900/50 rounded-lg transition-colors") do
-
concat('<svg class="w-8 h-8 text-slate-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>'.html_safe)
-
concat(content_tag(:span, "Click to upload or drag and drop", class: "text-sm text-slate-500 dark:text-slate-400"))
-
then: 0
else: 0
concat(content_tag(:span, "PNG, JPG, WebP up to 10MB", class: "text-xs text-slate-400 mt-1")) if is_image
-
end)
-
end)
-
end
-
end
-
-
1
def render_code_editor(f, field, _resource)
-
content_tag(:div, class: "relative", data: { controller: "admin-suite--code-editor" }) do
-
f.text_area(field.name,
-
class: "w-full font-mono text-sm bg-slate-900 text-slate-100 p-4 rounded-lg border border-slate-700 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500",
-
rows: field.rows || 12,
-
placeholder: field.placeholder,
-
data: { "admin-suite--code-editor-target": "textarea" })
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module AdminSuite
-
1
module IconHelper
-
# Renders an icon for AdminSuite using the configured renderer.
-
#
-
# Default behavior uses lucide-rails (LucideRails::IconProvider) if available.
-
# Back-compat: if `name` looks like raw SVG markup, it is returned as HTML safe.
-
#
-
# @param name [String, Symbol] icon name (e.g. "settings") OR raw svg string
-
# @param opts [Hash] passed to the underlying renderer (e.g. class:, stroke_width:)
-
# @return [ActiveSupport::SafeBuffer, String]
-
1
def admin_suite_icon(name, **opts)
-
114
then: 0
else: 114
return "".html_safe if name.blank?
-
-
114
raw = name.to_s
-
114
then: 0
else: 114
if raw.lstrip.start_with?("<svg")
-
return raw.html_safe
-
end
-
-
114
renderer = AdminSuite.config.icon_renderer
-
114
then: 0
else: 114
return renderer.call(raw, self, **opts) if renderer.respond_to?(:call)
-
-
# lucide-rails provides stripped SVG paths via IconProvider; we wrap them.
-
114
then: 114
else: 0
if defined?(::LucideRails::IconProvider)
-
114
default_class = "w-4 h-4"
-
114
css_class = [ default_class, opts[:class] ].compact.join(" ")
-
114
stroke_width = opts.fetch(:stroke_width, 2)
-
114
title = opts[:title]
-
-
begin
-
114
inner = ::LucideRails::IconProvider.icon(raw)
-
rescue ArgumentError
-
inner = nil
-
end
-
-
114
then: 114
else: 0
if inner.present?
-
114
return content_tag(
-
:svg,
-
114
then: 0
else: 114
(title.present? ? content_tag(:title, title) + inner.html_safe : inner.html_safe),
-
class: css_class,
-
xmlns: "http://www.w3.org/2000/svg",
-
width: "24",
-
height: "24",
-
viewBox: "0 0 24 24",
-
fill: "none",
-
stroke: "currentColor",
-
"stroke-width" => stroke_width,
-
"stroke-linecap" => "round",
-
"stroke-linejoin" => "round",
-
"aria-hidden" => "true",
-
focusable: "false"
-
)
-
end
-
end
-
-
# Safety fallback if lucide-rails isn't available in the host app for any reason.
-
content_tag(:span, "", class: opts[:class] || "inline-block w-4 h-4", title: raw)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module AdminSuite
-
1
module PanelsHelper
-
# Renders a portal dashboard rows grid.
-
#
-
# @param rows [Array<AdminSuite::UI::RowDefinition>]
-
1
def render_dashboard_rows(rows)
-
12
then: 0
else: 12
return "" if rows.blank?
-
-
12
content_tag(:div, class: "space-y-6") do
-
12
rows.each do |row|
-
12
concat(content_tag(:div, class: "grid grid-cols-1 lg:grid-cols-12 gap-6") do
-
12
Array(row.panels).each do |panel|
-
42
concat(render_panel(panel))
-
end
-
end)
-
end
-
end
-
end
-
-
# Renders a single panel by selecting a partial.
-
#
-
# Host apps can override by setting `AdminSuite.config.partials[:panel_<type>]`.
-
#
-
# @param panel [AdminSuite::UI::PanelDefinition]
-
1
def render_panel(panel)
-
42
type = panel.type.to_sym
-
42
override = AdminSuite.config.partials[:"panel_#{type}"] rescue nil
-
42
partial = override.presence || "admin_suite/panels/#{type}"
-
42
span = (panel.options[:span] || 12).to_i
-
42
then: 0
else: 42
span = 12 if span < 1
-
42
then: 0
else: 42
span = 12 if span > 12
-
-
# Avoid dynamic Tailwind class generation (e.g. `lg:col-span-#{span}`),
-
# which would otherwise require a safelist.
-
42
content_tag(:div, style: "grid-column: span #{span} / span #{span};") do
-
42
render partial:, locals: { panel: panel }
-
end
-
end
-
-
# Evaluates a panel option, calling Procs if needed.
-
#
-
# @param value [Object, Proc]
-
# @return [Object]
-
1
def panel_eval(value)
-
66
then: 12
else: 54
return value.call if value.is_a?(Proc) && value.arity == 0
-
54
then: 12
else: 42
return value.call(self) if value.is_a?(Proc) && value.arity == 1
-
42
value
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module AdminSuite
-
1
module ThemeHelper
-
1
def admin_suite_theme
-
6
(AdminSuite.config.theme || {}).symbolize_keys
-
rescue StandardError
-
{}
-
end
-
-
1
def theme_primary
-
admin_suite_theme[:primary]
-
end
-
-
1
def theme_secondary
-
admin_suite_theme[:secondary]
-
end
-
-
# Returns a <style> tag that scopes theme variables to AdminSuite.
-
#
-
# This is the core of engine-build mode theming: UI classes stay static
-
# (no `bg-#{...}`), and color changes are driven by CSS variables.
-
1
def admin_suite_theme_style_tag
-
6
theme = admin_suite_theme
-
-
6
primary = theme[:primary]
-
6
secondary = theme[:secondary]
-
-
primary_name =
-
6
then: 0
if AdminSuite::ThemePalette.hex?(primary)
-
nil
-
else: 6
else
-
6
AdminSuite::ThemePalette.normalize_color(primary, default_name: :indigo)
-
end
-
-
secondary_name =
-
6
then: 0
if AdminSuite::ThemePalette.hex?(secondary)
-
nil
-
else: 6
else
-
6
AdminSuite::ThemePalette.normalize_color(secondary, default_name: :purple)
-
end
-
-
# Primary variables
-
6
then: 0
else: 6
primary_600 = AdminSuite::ThemePalette.hex?(primary) ? primary : AdminSuite::ThemePalette.resolve(primary_name, 600, fallback: "#4f46e5")
-
6
then: 0
else: 6
primary_700 = AdminSuite::ThemePalette.hex?(primary) ? primary : AdminSuite::ThemePalette.resolve(primary_name, 700, fallback: "#4338ca")
-
-
# Sidebar gradient variables (dark shades)
-
6
sidebar_from = AdminSuite::ThemePalette.resolve(primary_name || "indigo", 900, fallback: "#312e81")
-
6
sidebar_via = AdminSuite::ThemePalette.resolve(primary_name || "indigo", 800, fallback: "#3730a3")
-
sidebar_to =
-
6
then: 0
if AdminSuite::ThemePalette.hex?(secondary)
-
secondary
-
else: 6
else
-
6
AdminSuite::ThemePalette.resolve(secondary_name || "purple", 900, fallback: "#581c87")
-
end
-
-
6
css = <<~CSS
-
body.admin-suite {
-
--admin-suite-primary: #{primary_600};
-
--admin-suite-primary-hover: #{primary_700};
-
--admin-suite-sidebar-from: #{sidebar_from};
-
--admin-suite-sidebar-via: #{sidebar_via};
-
--admin-suite-sidebar-to: #{sidebar_to};
-
}
-
CSS
-
-
6
content_tag(:style, css.html_safe)
-
end
-
-
1
def theme_link_class
-
12
"admin-suite-link"
-
end
-
-
1
def theme_link_hover_text_class
-
"admin-suite-link-hover"
-
end
-
-
1
def theme_btn_primary_class
-
"admin-suite-btn-primary"
-
end
-
-
1
def theme_btn_primary_small_class
-
"admin-suite-btn-primary admin-suite-btn-primary--sm"
-
end
-
-
1
def theme_badge_primary_class
-
6
"admin-suite-badge-primary"
-
end
-
-
1
def theme_focus_ring_class
-
"admin-suite-focus-ring"
-
end
-
-
1
def theme_sidebar_gradient_class
-
# Deprecated: gradient is now CSS-variable driven (see `admin_suite_theme_style_tag`).
-
""
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
AdminSuite::Engine.routes.draw do
-
1
root to: "dashboard#index"
-
-
# Docs viewer (host filesystem-backed). Must be defined before `:portal` route.
-
1
get "docs(/)", to: "docs#index", as: :docs
-
1
get "docs/*path", to: "docs#show", as: :doc, format: false
-
-
# Portal dashboards (e.g. /ops, /email). Accept optional trailing slash.
-
1
get ":portal(/)", to: "portals#show", as: :portal
-
-
# Generic resource routes (dynamic)
-
1
scope ":portal/:resource_name" do
-
1
get "/", to: "resources#index", as: :resources
-
1
get "/new", to: "resources#new", as: :new_resource
-
1
post "/", to: "resources#create"
-
1
get "/:id", to: "resources#show", as: :resource
-
1
get "/:id/edit", to: "resources#edit", as: :edit_resource
-
1
patch "/:id", to: "resources#update"
-
1
put "/:id", to: "resources#update"
-
1
delete "/:id", to: "resources#destroy"
-
-
1
post "/:id/execute_action/:action_name", to: "resources#execute_action", as: :execute_action
-
1
post "/bulk_action/:action_name", to: "resources#bulk_action", as: :bulk_action
-
end
-
-
1
post ":portal/:resource_name/:id/toggle", to: "resources#toggle", as: :resource_toggle
-
end
-
# frozen_string_literal: true
-
-
1
module Admin
-
1
module Base
-
1
class ActionExecutor
-
1
attr_reader :resource_class, :action_name, :actor
-
-
1
alias_method :current_user, :actor
-
-
1
Result = Struct.new(:success, :message, :redirect_url, :errors, keyword_init: true) do
-
1
def success? = success
-
1
def failure? = !success
-
end
-
-
1
def initialize(resource_class, action_name, actor)
-
@resource_class = resource_class
-
@action_name = action_name
-
@actor = actor
-
end
-
-
1
def execute_member(record, params = {})
-
action = find_member_action
-
else: 0
then: 0
return failure_result("Action not found") unless action
-
else: 0
then: 0
return failure_result("Condition not met") unless condition_met?(action, record)
-
execute_action(action, record, params)
-
end
-
-
1
def execute_bulk(records, params = {})
-
action = find_bulk_action
-
else: 0
then: 0
return failure_result("Action not found") unless action
-
-
results = records.map { |record| execute_action(action, record, params) }
-
success_count = results.count(&:success?)
-
failure_count = results.count(&:failure?)
-
-
then: 0
if failure_count.zero?
-
else: 0
success_result("Successfully processed #{success_count} records")
-
then: 0
elsif success_count.zero?
-
failure_result("Failed to process all #{failure_count} records")
-
else: 0
else
-
success_result("Processed #{success_count} records, #{failure_count} failed")
-
end
-
end
-
-
1
def execute_collection(scope, params = {})
-
action = find_collection_action
-
else: 0
then: 0
return failure_result("Action not found") unless action
-
execute_action(action, scope, params)
-
end
-
-
1
def action_definition
-
find_member_action || find_bulk_action || find_collection_action
-
end
-
-
1
private
-
-
1
def actions_config = @resource_class.actions_config
-
-
1
def find_member_action
-
else: 0
then: 0
return nil unless actions_config
-
actions_config.member_actions.find { |a| a.name == action_name }
-
end
-
-
1
def find_bulk_action
-
else: 0
then: 0
return nil unless actions_config
-
actions_config.bulk_actions.find { |a| a.name == action_name }
-
end
-
-
1
def find_collection_action
-
else: 0
then: 0
return nil unless actions_config
-
actions_config.collection_actions.find { |a| a.name == action_name }
-
end
-
-
1
def condition_met?(action, record)
-
then: 0
else: 0
return evaluate_condition(action.if_condition, record) if action.if_condition.present?
-
then: 0
else: 0
return !evaluate_condition(action.unless_condition, record) if action.unless_condition.present?
-
true
-
end
-
-
1
def evaluate_condition(condition_proc, record)
-
then: 0
else: 0
condition_proc.arity.zero? ? record.instance_exec(&condition_proc) : condition_proc.call(record)
-
end
-
-
1
def execute_action(action, target, params)
-
result =
-
then: 0
if target.respond_to?(action.name)
-
else: 0
execute_model_method(target, action)
-
then: 0
elsif target.respond_to?("#{action.name}!")
-
execute_model_method(target, action, bang: true)
-
else: 0
else
-
handler_class = find_handler_class(action)
-
then: 0
else: 0
handler_class ? execute_handler(handler_class, target, params) : failure_result("No handler found for action: #{action.name}")
-
end
-
-
notify_action_executed(action, target, params, result)
-
result
-
rescue StandardError => e
-
result = failure_result("Error: #{e.message}")
-
notify_action_executed(action, target, params, result)
-
result
-
end
-
-
1
def execute_model_method(record, action, bang: false)
-
then: 0
else: 0
method_name = bang ? "#{action.name}!" : action.name
-
record.public_send(method_name)
-
success_result("#{action.label} completed successfully")
-
rescue ActiveRecord::RecordInvalid => e
-
failure_result("Validation failed: #{e.record.errors.full_messages.join(', ')}")
-
rescue AASM::InvalidTransition => e
-
failure_result("Invalid state transition: #{e.message}")
-
end
-
-
1
def find_handler_class(action)
-
then: 0
else: 0
if defined?(AdminSuite) && AdminSuite.config.resolve_action_handler.present?
-
resolved = AdminSuite.config.resolve_action_handler.call(resource_class, action.name)
-
then: 0
else: 0
return resolved if resolved
-
end
-
-
handler_name = "#{resource_class.resource_name.camelize}#{action.name.to_s.camelize}Action"
-
"Admin::Actions::#{handler_name}".constantize
-
rescue NameError
-
nil
-
end
-
-
1
def execute_handler(handler_class, target, params)
-
handler_class.new(target, actor, params).call
-
end
-
-
1
def success_result(message, redirect_url: nil)
-
Result.new(success: true, message: message, redirect_url: redirect_url, errors: [])
-
end
-
-
1
def failure_result(message, errors: [])
-
Result.new(success: false, message: message, redirect_url: nil, errors: errors)
-
end
-
-
1
def notify_action_executed(action, target, params, result)
-
else: 0
then: 0
return unless defined?(AdminSuite)
-
hook = AdminSuite.config.on_action_executed
-
else: 0
then: 0
return unless hook
-
-
hook.call(
-
actor: actor,
-
action_name: action.name,
-
resource_class: resource_class,
-
subject: target,
-
params: params,
-
result: result
-
)
-
rescue StandardError
-
nil
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module Admin
-
1
module Base
-
1
class ActionHandler
-
1
attr_reader :record, :actor, :params
-
-
1
alias_method :current_user, :actor
-
-
1
def initialize(record, actor, params = {})
-
@record = record
-
@actor = actor
-
@params = params
-
end
-
-
1
def call
-
raise NotImplementedError, "Subclasses must implement #call"
-
end
-
-
1
protected
-
-
1
def success(message, redirect_url: nil)
-
Admin::Base::ActionExecutor::Result.new(success: true, message: message, redirect_url: redirect_url, errors: [])
-
end
-
-
1
def failure(message, errors: [])
-
Admin::Base::ActionExecutor::Result.new(success: false, message: message, redirect_url: nil, errors: errors)
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module Admin
-
1
module Base
-
1
class FilterBuilder
-
1
attr_reader :resource_class, :params
-
-
1
def initialize(resource_class, params)
-
@resource_class = resource_class
-
@params = params
-
end
-
-
1
def apply(scope)
-
scope = apply_search(scope)
-
scope = apply_filters(scope)
-
scope = apply_sort(scope)
-
scope
-
end
-
-
1
def filter_params
-
else: 0
then: 0
return {} unless index_config
-
-
permitted_keys = [ :search, :sort, :sort_direction, :page ]
-
permitted_keys += index_config.filters_list.map(&:name)
-
-
params.permit(*permitted_keys).to_h.symbolize_keys
-
end
-
-
1
private
-
-
1
def index_config
-
@resource_class.index_config
-
end
-
-
1
def apply_search(scope)
-
else: 0
then: 0
return scope unless index_config
-
then: 0
else: 0
return scope if params[:search].blank?
-
then: 0
else: 0
return scope if index_config.searchable_fields.empty?
-
then: 0
else: 0
return scope if params[:search].to_s.length < 3
-
-
search_term = "%#{params[:search]}%"
-
conditions = index_config.searchable_fields.map { |field| "#{field} ILIKE :search" }.join(" OR ")
-
scope.where(conditions, search: search_term)
-
end
-
-
1
def apply_filters(scope)
-
else: 0
then: 0
return scope unless index_config
-
-
index_config.filters_list.each do |filter|
-
scope = apply_filter(scope, filter)
-
end
-
scope
-
end
-
-
1
def apply_filter(scope, filter)
-
# Some "filters" in the UI are really just controls (e.g. sort dropdown).
-
# They are handled elsewhere (`apply_sort`) and must not be turned into SQL.
-
then: 0
else: 0
return scope if %i[sort sort_direction direction page search].include?(filter.name.to_sym)
-
-
value = params[filter.name]
-
then: 0
else: 0
return scope if value.blank?
-
-
then: 0
else: 0
if filter.respond_to?(:apply) && filter.apply.present?
-
return apply_custom_filter(scope, filter.apply, value)
-
end
-
-
case filter.type
-
when: 0
when :text, :search
-
scope.where("#{filter.field} ILIKE ?", "%#{value}%")
-
when: 0
when :select
-
scope.where(filter.field => value)
-
when: 0
when :toggle, :boolean
-
bool_value = ActiveModel::Type::Boolean.new.cast(value)
-
scope.where(filter.field => bool_value)
-
when: 0
when :number
-
scope.where(filter.field => value.to_i)
-
when: 0
when :date
-
date = Date.parse(value) rescue nil
-
else: 0
then: 0
return scope unless date
-
scope.where(filter.field => date.all_day)
-
when: 0
when :date_range
-
from_date = params["#{filter.name}_from"].presence
-
to_date = params["#{filter.name}_to"].presence
-
then: 0
else: 0
scope = scope.where("#{filter.field} >= ?", Date.parse(from_date)) if from_date.present?
-
then: 0
else: 0
scope = scope.where("#{filter.field} <= ?", Date.parse(to_date).end_of_day) if to_date.present?
-
scope
-
when: 0
when :association
-
scope.where("#{filter.field}_id" => value)
-
else: 0
else
-
scope
-
end
-
end
-
-
1
def apply_custom_filter(scope, filter_proc, value)
-
then: 0
else: 0
filter_proc.arity == 2 ? filter_proc.call(scope, value) : filter_proc.call(scope, value, params)
-
end
-
-
1
def apply_sort(scope)
-
else: 0
then: 0
return scope unless index_config
-
-
sort_field = params[:sort].presence || index_config.default_sort
-
else: 0
then: 0
return scope unless sort_field
-
-
else: 0
then: 0
unless index_config.sortable_fields.include?(sort_field.to_sym)
-
sort_field = index_config.default_sort
-
end
-
else: 0
then: 0
return scope unless sort_field
-
-
direction_param = params[:sort_direction].presence || params[:direction].presence
-
direction =
-
then: 0
if direction_param.present?
-
then: 0
else: 0
direction_param.to_sym == :desc ? :desc : :asc
-
else: 0
else
-
(index_config.default_sort_direction || :desc).to_sym
-
end
-
-
scope.order(sort_field => direction)
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module Admin
-
1
module Base
-
# Base class for admin resource definitions
-
#
-
# Provides a declarative DSL for defining admin resources with:
-
# - Index configuration (columns, filters, search, sort, stats)
-
# - Form configuration (fields with various types)
-
# - Actions (single and bulk)
-
# - Show page sections
-
# - Export capabilities
-
#
-
# @example
-
# class Admin::Resources::CompanyResource < Admin::Base::Resource
-
# model Company
-
# portal :ops
-
# section :content
-
#
-
# index do
-
# searchable :name, :website
-
# sortable :name, :created_at, default: :name
-
#
-
# columns do
-
# column :name
-
# column :job_listings, -> (c) { c.job_listings.count }
-
# end
-
#
-
# filters do
-
# filter :search, type: :text
-
# filter :status, type: :select, options: %w[active inactive]
-
# end
-
# end
-
#
-
# form do
-
# field :name, required: true
-
# field :website, type: :url
-
# field :is_active, type: :toggle
-
# end
-
# end
-
1
class Resource
-
1
class << self
-
# Model configuration
-
1
attr_reader :model_class, :portal_name, :section_name, :nav_label, :nav_icon, :nav_order
-
-
# Index configuration
-
1
attr_reader :index_config
-
-
# Form configuration
-
1
attr_reader :form_config
-
-
# Show configuration
-
1
attr_reader :show_config
-
-
# Actions configuration
-
1
attr_reader :actions_config
-
-
# Export configuration
-
1
attr_reader :export_formats
-
-
# Sets the model class for this resource
-
#
-
# @param klass [Class] The ActiveRecord model class
-
# @return [void]
-
1
def model(klass)
-
@model_class = klass
-
end
-
-
# Sets the portal this resource belongs to
-
#
-
# @param name [Symbol] Portal name (:ops or :ai)
-
# @return [void]
-
1
def portal(name)
-
@portal_name = name
-
end
-
-
# Sets the section within the portal
-
#
-
# @param name [Symbol] Section name
-
# @return [void]
-
1
def section(name)
-
@section_name = name
-
end
-
-
# Navigation metadata for this resource.
-
#
-
# @param label [String, nil] override label used in nav
-
# @param icon [String, Symbol, nil] lucide icon name (or raw svg string)
-
# @param order [Integer, nil] sort order within section
-
# @return [void]
-
1
def nav(label: nil, icon: nil, order: nil)
-
then: 0
else: 0
@nav_label = label if label.present?
-
then: 0
else: 0
@nav_icon = icon if icon.present?
-
else: 0
then: 0
@nav_order = order unless order.nil?
-
end
-
-
# Convenience setter/getter for nav icon.
-
#
-
# @param name [String, Symbol, nil]
-
# @return [String, Symbol, nil]
-
1
def icon(name = nil)
-
then: 0
else: 0
@nav_icon = name if name.present?
-
@nav_icon
-
end
-
-
# Convenience setter/getter for nav label.
-
#
-
# @param name [String, nil]
-
# @return [String, nil]
-
1
def label(name = nil)
-
then: 0
else: 0
@nav_label = name if name.present?
-
@nav_label
-
end
-
-
# Convenience setter/getter for nav order.
-
#
-
# @param value [Integer, nil]
-
# @return [Integer, nil]
-
1
def order(value = nil)
-
else: 0
then: 0
@nav_order = value unless value.nil?
-
@nav_order
-
end
-
-
# Configures the index view
-
#
-
# @yield Block for index configuration
-
# @return [void]
-
1
def index(&block)
-
@index_config = IndexConfig.new
-
then: 0
else: 0
@index_config.instance_eval(&block) if block_given?
-
end
-
-
# Configures the form (new/edit)
-
#
-
# @yield Block for form configuration
-
# @return [void]
-
1
def form(&block)
-
@form_config = FormConfig.new
-
then: 0
else: 0
@form_config.instance_eval(&block) if block_given?
-
end
-
-
# Configures the show view
-
#
-
# @yield Block for show configuration
-
# @return [void]
-
1
def show(&block)
-
@show_config = ShowConfig.new
-
then: 0
else: 0
@show_config.instance_eval(&block) if block_given?
-
end
-
-
# Configures actions
-
#
-
# @yield Block for actions configuration
-
# @return [void]
-
1
def actions(&block)
-
@actions_config = ActionsConfig.new
-
then: 0
else: 0
@actions_config.instance_eval(&block) if block_given?
-
end
-
-
# Configures export formats
-
#
-
# @param formats [Array<Symbol>] Export formats (:json, :csv)
-
# @return [void]
-
1
def exportable(*formats)
-
@export_formats = formats
-
end
-
-
# Returns the resource name derived from class name
-
#
-
# @return [String]
-
1
def resource_name
-
name.demodulize.sub(/Resource$/, "").underscore
-
end
-
-
# Returns the plural resource name
-
#
-
# @return [String]
-
1
def resource_name_plural
-
resource_name.pluralize
-
end
-
-
# Returns the human-readable name
-
#
-
# @return [String]
-
1
def human_name
-
then: 0
else: 0
then: 0
else: 0
model_class&.model_name&.human || resource_name.humanize
-
end
-
-
# Returns the human-readable plural name
-
#
-
# @return [String]
-
1
def human_name_plural
-
then: 0
else: 0
then: 0
else: 0
model_class&.model_name&.human(count: 2) || resource_name.pluralize.humanize
-
end
-
-
# Returns all registered resources
-
#
-
# @return [Array<Class>]
-
1
def registered_resources
-
150
@registered_resources ||= []
-
end
-
-
# Clears the registry (useful for development reloads).
-
#
-
# @return [void]
-
1
def reset_registry!
-
@registered_resources = []
-
end
-
-
# Called when a subclass is created
-
1
def inherited(subclass)
-
super
-
then: 0
else: 0
then: 0
else: 0
return if subclass.name&.include?("Base")
-
-
existing_idx = registered_resources.index { |r| r.name == subclass.name }
-
then: 0
if existing_idx
-
registered_resources[existing_idx] = subclass
-
else: 0
else
-
registered_resources << subclass
-
end
-
end
-
-
# Returns resources for a specific portal
-
#
-
# @param portal [Symbol] Portal name
-
# @return [Array<Class>]
-
1
def resources_for_portal(portal)
-
12
registered_resources.select { |r| r.portal_name == portal }
-
end
-
-
# Returns resources for a specific section
-
#
-
# @param portal [Symbol] Portal name
-
# @param section [Symbol] Section name
-
# @return [Array<Class>]
-
1
def resources_for_section(portal, section)
-
registered_resources.select { |r| r.portal_name == portal && r.section_name == section }
-
end
-
end
-
-
# Index view configuration
-
1
class IndexConfig
-
1
attr_reader :searchable_fields, :sortable_fields, :default_sort, :default_sort_direction,
-
:columns_list, :filters_list, :stats_list, :per_page
-
-
1
def initialize
-
@searchable_fields = []
-
@sortable_fields = []
-
@default_sort = nil
-
@default_sort_direction = :desc
-
@columns_list = []
-
@filters_list = []
-
@stats_list = []
-
@per_page = 25
-
end
-
-
1
def searchable(*fields)
-
@searchable_fields = fields
-
end
-
-
1
def sortable(*fields, default: nil, direction: :desc)
-
then: 0
else: 0
@sortable_fields = fields if fields.any?
-
@default_sort = default || fields.first
-
@default_sort_direction = direction
-
end
-
-
1
def paginate(count)
-
@per_page = count
-
end
-
-
1
def columns(&block)
-
builder = ColumnsBuilder.new
-
then: 0
else: 0
builder.instance_eval(&block) if block_given?
-
@columns_list = builder.columns
-
end
-
-
1
def filters(&block)
-
builder = FiltersBuilder.new
-
then: 0
else: 0
builder.instance_eval(&block) if block_given?
-
@filters_list = builder.filters
-
end
-
-
1
def stats(&block)
-
builder = StatsBuilder.new
-
then: 0
else: 0
builder.instance_eval(&block) if block_given?
-
@stats_list = builder.stats
-
end
-
end
-
-
1
class ColumnsBuilder
-
1
attr_reader :columns
-
-
1
def initialize
-
@columns = []
-
end
-
-
1
def column(name, content = nil, **options)
-
@columns << ColumnDefinition.new(
-
name: name,
-
content: content,
-
render: options[:render],
-
header: options[:header] || name.to_s.humanize,
-
css_class: options[:class],
-
type: options[:type],
-
toggle_field: options[:toggle_field],
-
label_color: options[:label_color],
-
label_size: options[:label_size],
-
sortable: options[:sortable] || false
-
)
-
end
-
end
-
-
1
ColumnDefinition = Struct.new(:name, :content, :render, :header, :css_class, :type, :toggle_field, :label_color, :label_size, :sortable, keyword_init: true)
-
-
1
class FiltersBuilder
-
1
attr_reader :filters
-
-
1
def initialize
-
@filters = []
-
end
-
-
1
def filter(name, **options)
-
then: 0
else: 0
select_options = options.key?(:options) ? options[:options] : options[:collection]
-
@filters << FilterDefinition.new(
-
name: name,
-
type: options[:type] || :text,
-
label: options[:label] || name.to_s.humanize,
-
placeholder: options[:placeholder],
-
options: select_options,
-
field: options[:field] || name,
-
apply: options[:apply]
-
)
-
end
-
end
-
-
1
FilterDefinition = Struct.new(:name, :type, :label, :placeholder, :options, :field, :apply, keyword_init: true)
-
-
1
class StatsBuilder
-
1
attr_reader :stats
-
-
1
def initialize
-
@stats = []
-
end
-
-
1
def stat(name, calculator, **options)
-
@stats << StatDefinition.new(
-
name: name,
-
calculator: calculator,
-
color: options[:color]
-
)
-
end
-
end
-
-
1
StatDefinition = Struct.new(:name, :calculator, :color, keyword_init: true)
-
-
1
class FormConfig
-
1
attr_reader :fields_list
-
-
1
def initialize
-
@fields_list = []
-
end
-
-
1
def field(name, **options)
-
@fields_list << FieldDefinition.new(
-
name: name,
-
type: options[:type] || :text,
-
required: options[:required] || false,
-
label: options[:label] || name.to_s.humanize,
-
help: options[:help],
-
placeholder: options[:placeholder],
-
collection: options[:collection],
-
create_url: options[:create_url],
-
accept: options[:accept],
-
rows: options[:rows],
-
readonly: options[:readonly] || false,
-
if_condition: options[:if],
-
unless_condition: options[:unless],
-
multiple: options[:multiple] || false,
-
creatable: options[:creatable] || false,
-
preview: options[:preview] != false,
-
variants: options[:variants],
-
label_color: options[:label_color],
-
label_size: options[:label_size]
-
)
-
end
-
-
1
def section(title, **options, &block)
-
@fields_list << SectionDefinition.new(
-
title: title,
-
description: options[:description],
-
collapsible: options[:collapsible] || false,
-
collapsed: options[:collapsed] || false
-
)
-
then: 0
else: 0
instance_eval(&block) if block_given?
-
@fields_list << SectionEnd.new
-
end
-
-
1
def row(**options, &block)
-
@fields_list << RowDefinition.new(cols: options[:cols] || 2)
-
then: 0
else: 0
instance_eval(&block) if block_given?
-
@fields_list << RowEnd.new
-
end
-
end
-
-
1
FieldDefinition = Struct.new(
-
:name, :type, :required, :label, :help, :placeholder,
-
:collection, :create_url, :accept, :rows, :readonly,
-
:if_condition, :unless_condition, :multiple, :creatable,
-
:preview, :variants, :label_color, :label_size,
-
keyword_init: true
-
)
-
-
1
SectionDefinition = Struct.new(:title, :description, :collapsible, :collapsed, keyword_init: true)
-
1
SectionEnd = Class.new
-
-
1
RowDefinition = Struct.new(:cols, keyword_init: true)
-
1
RowEnd = Class.new
-
-
1
class ShowConfig
-
1
attr_reader :sidebar_sections, :main_sections
-
-
1
def initialize
-
@sidebar_sections = []
-
@main_sections = []
-
end
-
-
1
def section(name, **options)
-
@main_sections << build_section(name, options)
-
end
-
-
1
def sidebar(&block)
-
@current_target = :sidebar
-
then: 0
else: 0
instance_eval(&block) if block_given?
-
@current_target = nil
-
end
-
-
1
def main(&block)
-
@current_target = :main
-
then: 0
else: 0
instance_eval(&block) if block_given?
-
@current_target = nil
-
end
-
-
1
def panel(name, **options)
-
section_def = build_section(name, options)
-
-
case @current_target
-
when: 0
when :sidebar
-
@sidebar_sections << section_def
-
else: 0
else
-
@main_sections << section_def
-
end
-
end
-
-
1
def sections_list
-
@main_sections
-
end
-
-
1
private
-
-
1
def build_section(name, options)
-
ShowSectionDefinition.new(
-
name: name,
-
fields: options[:fields] || [],
-
association: options[:association],
-
limit: options[:limit],
-
render: options[:render],
-
title: options[:title] || name.to_s.humanize,
-
display: options[:display] || :list,
-
columns: options[:columns] || [],
-
link_to: options[:link_to],
-
resource: options[:resource],
-
paginate: options[:paginate] || options[:pagination] || false,
-
per_page: options[:per_page],
-
collapsible: options[:collapsible] || false,
-
collapsed: options[:collapsed] || false
-
)
-
end
-
end
-
-
1
ShowSectionDefinition = Struct.new(
-
:name, :fields, :association, :limit, :render, :title,
-
:display, :columns, :link_to, :resource, :paginate, :per_page, :collapsible, :collapsed,
-
keyword_init: true
-
)
-
-
1
class ActionsConfig
-
1
attr_reader :member_actions, :collection_actions, :bulk_actions
-
-
1
def initialize
-
@member_actions = []
-
@collection_actions = []
-
@bulk_actions = []
-
end
-
-
1
def action(name, **options)
-
@member_actions << ActionDefinition.new(
-
name: name,
-
method: options[:method] || :post,
-
confirm: options[:confirm],
-
type: options[:type] || :button,
-
label: options[:label] || name.to_s.humanize,
-
icon: options[:icon],
-
color: options[:color],
-
if_condition: options[:if],
-
unless_condition: options[:unless]
-
)
-
end
-
-
1
def collection_action(name, **options)
-
@collection_actions << ActionDefinition.new(
-
name: name,
-
method: options[:method] || :post,
-
confirm: options[:confirm],
-
type: options[:type] || :button,
-
label: options[:label] || name.to_s.humanize,
-
icon: options[:icon],
-
color: options[:color]
-
)
-
end
-
-
1
def bulk_action(name, **options)
-
@bulk_actions << ActionDefinition.new(
-
name: name,
-
method: options[:method] || :post,
-
confirm: options[:confirm],
-
type: options[:type] || :button,
-
label: options[:label] || name.to_s.humanize,
-
icon: options[:icon],
-
color: options[:color]
-
)
-
end
-
end
-
-
1
ActionDefinition = Struct.new(
-
:name, :method, :confirm, :type, :label, :icon, :color,
-
:if_condition, :unless_condition,
-
keyword_init: true
-
)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
begin
-
1
require "lucide-rails"
-
rescue LoadError
-
# Host app may choose a different icon provider via `AdminSuite.config.icon_renderer`.
-
end
-
-
1
require "admin_suite/version"
-
1
require "admin_suite/configuration"
-
1
require "admin_suite/markdown_renderer"
-
1
require "admin_suite/theme_palette"
-
1
require "admin_suite/portal_registry"
-
1
require "admin_suite/portal_definition"
-
1
require "admin_suite/ui/form_field_renderer"
-
1
require "admin_suite/ui/show_value_formatter"
-
1
require "admin_suite/engine"
-
-
1
module AdminSuite
-
1
class << self
-
# @return [AdminSuite::Configuration]
-
1
def config
-
511
@config ||= Configuration.new
-
end
-
-
# @yieldparam config [AdminSuite::Configuration]
-
# @return [AdminSuite::Configuration]
-
1
def configure
-
2
yield(config)
-
2
config
-
end
-
-
# Defines (or updates) a portal using a Ruby DSL.
-
#
-
# Host apps typically place these in `app/admin/portals/*.rb`.
-
#
-
# @param key [Symbol, String]
-
# @yield Portal definition DSL
-
# @return [AdminSuite::PortalDefinition]
-
1
def portal(key, &block)
-
5
definition = PortalDefinition.new(key)
-
5
then: 5
else: 0
definition.instance_eval(&block) if block_given?
-
5
PortalRegistry.register(definition)
-
5
definition
-
end
-
-
# @return [Hash{Symbol=>AdminSuite::PortalDefinition}]
-
1
def portal_definitions
-
PortalRegistry.all
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module AdminSuite
-
# Configuration object for AdminSuite.
-
1
class Configuration
-
1
attr_accessor :authenticate,
-
:current_actor,
-
:authorize,
-
:resource_globs,
-
:portal_globs,
-
:portals,
-
:custom_renderers,
-
:icon_renderer,
-
:docs_url,
-
:docs_path,
-
:partials,
-
:theme,
-
:host_stylesheet,
-
:tailwind_cdn,
-
:on_action_executed,
-
:resolve_action_handler
-
-
1
def initialize
-
1
@authenticate = nil
-
1
@current_actor = nil
-
1
@authorize = nil
-
1
@resource_globs = []
-
1
@portal_globs = []
-
1
@portals = {}
-
1
@custom_renderers = {}
-
1
@icon_renderer = nil
-
1
@docs_url = nil
-
1
@docs_path = Rails.root.join("docs")
-
1
@partials = {}
-
1
@theme = { primary: :indigo, secondary: :purple }
-
1
@host_stylesheet = nil
-
1
@tailwind_cdn = true
-
1
@on_action_executed = nil
-
1
@resolve_action_handler = nil
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require "fileutils"
-
-
1
module AdminSuite
-
1
class Engine < ::Rails::Engine
-
1
isolate_namespace AdminSuite
-
-
1
initializer "admin_suite.watchable_dirs" do |app|
-
1
else: 0
then: 1
next unless Rails.env.development?
-
-
# Make local-engine edits reload without a full server restart.
-
app.config.watchable_dirs[root.join("app").to_s] = %w[rb erb js css]
-
app.config.watchable_dirs[root.join("lib").to_s] = %w[rb]
-
app.config.watchable_dirs[root.join("config").to_s] = %w[rb]
-
end
-
-
1
initializer "admin_suite.assets", before: "propshaft" do |app|
-
# Make engine JS/CSS available to the host asset pipeline (Propshaft/Sprockets).
-
1
app.config.assets.paths << root.join("app/javascript")
-
1
app.config.assets.paths << root.join("app/assets")
-
end
-
-
1
initializer "admin_suite.importmap", before: "importmap" do |app|
-
# Make engine-provided JS available to host apps using importmap-rails.
-
1
then: 1
else: 0
if app.config.respond_to?(:importmap) && app.config.importmap.respond_to?(:paths)
-
1
app.config.importmap.paths << root.join("config/importmap.rb")
-
end
-
end
-
-
1
initializer "admin_suite.configuration" do
-
# Provide sensible defaults for host apps.
-
1
AdminSuite.configure do |config|
-
1
then: 0
else: 1
config.resource_globs = [ Rails.root.join("app/admin/resources/*.rb").to_s ] if config.resource_globs.blank?
-
1
then: 0
else: 1
config.portal_globs = [ Rails.root.join("app/admin/portals/*.rb").to_s ] if config.portal_globs.blank?
-
then: 0
else: 1
config.portals = {
-
ops: { label: "Ops Portal", icon: "settings", color: :amber, order: 10 },
-
email: { label: "Email Portal", icon: "inbox", color: :emerald, order: 20 },
-
ai: { label: "AI Portal", icon: "cpu", color: :cyan, order: 30 },
-
assistant: { label: "Assistant Portal", icon: "message-circle", color: :violet, order: 40 }
-
1
} if config.portals.blank?
-
end
-
end
-
-
1
initializer "admin_suite.tailwind_build" do
-
1
else: 0
then: 1
next unless Rails.env.development?
-
-
# In development, ensure the engine stylesheet exists so the UI is usable
-
# without requiring host-specific Tailwind setup.
-
output = Rails.root.join("app/assets/builds/admin_suite_tailwind.css")
-
then: 0
else: 0
next if output.exist?
-
-
input = root.join("app/assets/tailwind/admin_suite.css")
-
FileUtils.mkdir_p(output.dirname)
-
-
system("tailwindcss", "-i", input.to_s, "-o", output.to_s)
-
rescue StandardError
-
# Best effort only; missing stylesheet will show up immediately in the UI.
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require "redcarpet"
-
1
require "rouge"
-
1
require "rouge/plugins/redcarpet"
-
-
1
module AdminSuite
-
# MarkdownRenderer converts markdown text into safe HTML with syntax highlighting.
-
#
-
# Uses Redcarpet for markdown parsing, Rouge for syntax highlighting,
-
# and extracts a table of contents from headings.
-
1
class MarkdownRenderer
-
1
attr_reader :markdown
-
-
1
def initialize(markdown)
-
4
@markdown = markdown.to_s
-
end
-
-
# @return [Hash{Symbol=>Object}]
-
1
def render
-
4
result = self.class.render_with_toc(markdown)
-
-
{
-
4
html: result[:html],
-
toc: result[:toc],
-
reading_time_minutes: reading_time_minutes
-
}
-
end
-
-
# @param text [String]
-
# @return [ActiveSupport::SafeBuffer]
-
1
def self.render(text)
-
renderer = HtmlRenderer.new
-
md = Redcarpet::Markdown.new(renderer, markdown_extensions)
-
md.render(text.to_s).html_safe
-
end
-
-
# @param text [String]
-
# @return [Hash{Symbol=>Object}]
-
1
def self.render_with_toc(text)
-
4
renderer = HtmlRenderer.new
-
4
md = Redcarpet::Markdown.new(renderer, markdown_extensions)
-
4
html = md.render(text.to_s).html_safe
-
4
{ html: html, toc: renderer.toc_items }
-
end
-
-
1
private
-
-
1
def self.markdown_extensions
-
4
{
-
autolink: true,
-
tables: true,
-
fenced_code_blocks: true,
-
strikethrough: true,
-
highlight: true,
-
superscript: true,
-
underline: true,
-
no_intra_emphasis: true,
-
space_after_headers: true,
-
lax_spacing: true
-
}
-
end
-
-
# Rough reading time estimate assuming 200 wpm.
-
# @return [Integer]
-
1
def reading_time_minutes
-
4
words = markdown.scan(/\b[\p{L}\p{N}']+\b/).size
-
4
[ (words / 200.0).ceil, 1 ].max
-
end
-
-
1
class HtmlRenderer < Redcarpet::Render::HTML
-
1
include Rouge::Plugins::Redcarpet
-
-
1
attr_reader :toc_items
-
-
1
def initialize(extensions = {})
-
4
super(extensions.merge(
-
hard_wrap: true,
-
link_attributes: { target: "_blank", rel: "noopener noreferrer" },
-
with_toc_data: true
-
))
-
4
@toc_items = []
-
4
@heading_ids = Hash.new(0)
-
end
-
-
1
def block_code(code, language)
-
8
language ||= "text"
-
8
lexer = Rouge::Lexer.find_fancy(language, code) || Rouge::Lexers::PlainText.new
-
8
formatter = Rouge::Formatters::HTML.new
-
8
highlighted = formatter.format(lexer.lex(code))
-
-
8
then: 7
else: 1
lang_label = language != "text" ? %(<span class="code-lang">#{language}</span>) : ""
-
8
%(<div class="code-block">#{lang_label}<pre class="highlight #{language}"><code>#{highlighted}</code></pre></div>)
-
end
-
-
1
def header(text, header_level)
-
54
base_slug = text.to_s.downcase.strip.gsub(/\s+/, "-").gsub(/[^\w-]/, "")
-
54
then: 0
else: 54
base_slug = "section" if base_slug.blank?
-
-
54
@heading_ids[base_slug] += 1
-
54
then: 0
else: 54
slug = @heading_ids[base_slug] > 1 ? "#{base_slug}-#{@heading_ids[base_slug]}" : base_slug
-
-
54
then: 51
else: 3
if header_level >= 2 && header_level <= 4
-
51
@toc_items << { level: header_level, id: slug, text: text }
-
end
-
-
54
%(<h#{header_level} id="#{slug}">#{text}</h#{header_level}>\n)
-
end
-
-
1
def table(header, body)
-
%(<table class="admin-suite-doc-table"><thead>#{header}</thead><tbody>#{body}</tbody></table>\n)
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require "admin_suite/ui/dashboard_definition"
-
-
1
module AdminSuite
-
1
class PortalDefinition
-
1
attr_reader :key
-
-
1
def initialize(key)
-
5
@key = key.to_sym
-
5
@label = nil
-
5
@icon = nil
-
5
@color = nil
-
5
@order = nil
-
5
@description = nil
-
5
@dashboard = nil
-
end
-
-
1
def label(value = nil)
-
5
then: 5
else: 0
@label = value if value.present?
-
5
@label
-
end
-
-
1
def icon(value = nil)
-
5
then: 5
else: 0
@icon = value if value.present?
-
5
@icon
-
end
-
-
1
def color(value = nil)
-
5
then: 5
else: 0
@color = value if value.present?
-
5
@color
-
end
-
-
1
def order(value = nil)
-
5
else: 0
then: 5
@order = value unless value.nil?
-
5
@order
-
end
-
-
1
def description(value = nil)
-
5
then: 5
else: 0
@description = value if value.present?
-
5
@description
-
end
-
-
1
def dashboard(&block)
-
5
@dashboard ||= UI::DashboardDefinition.new
-
5
then: 5
else: 0
UI::DashboardDSL.new(@dashboard).instance_eval(&block) if block_given?
-
5
@dashboard
-
end
-
-
1
def dashboard_definition
-
@dashboard
-
end
-
-
1
def to_nav_meta
-
{
-
675
label: @label,
-
icon: @icon,
-
color: @color,
-
order: @order,
-
description: @description
-
}.compact
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module AdminSuite
-
# Stores portal definitions registered via `AdminSuite.portal`.
-
1
module PortalRegistry
-
1
class << self
-
# @return [Hash{Symbol=>AdminSuite::PortalDefinition}]
-
1
def all
-
275
@all ||= {}
-
end
-
-
# @param definition [AdminSuite::PortalDefinition]
-
# @return [AdminSuite::PortalDefinition]
-
1
def register(definition)
-
5
all[definition.key] = definition
-
end
-
-
# @param key [Symbol, String]
-
# @return [AdminSuite::PortalDefinition, nil]
-
1
def fetch(key)
-
all[key.to_sym]
-
end
-
-
# Clears the registry (useful for development reloads).
-
#
-
# @return [void]
-
1
def reset!
-
@all = {}
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module AdminSuite
-
1
module ThemePalette
-
# Minimal Tailwind-like palette values (hex) for theming.
-
# We only include the shades AdminSuite uses.
-
COLORS = {
-
1
"slate" => { 100 => "#f1f5f9", 200 => "#e2e8f0", 500 => "#64748b", 600 => "#475569", 700 => "#334155", 800 => "#1e293b", 900 => "#0f172a" },
-
"indigo" => { 100 => "#e0e7ff", 200 => "#c7d2fe", 500 => "#6366f1", 600 => "#4f46e5", 700 => "#4338ca", 800 => "#3730a3", 900 => "#312e81" },
-
"purple" => { 100 => "#f3e8ff", 200 => "#e9d5ff", 500 => "#a855f7", 600 => "#9333ea", 700 => "#7e22ce", 800 => "#6b21a8", 900 => "#581c87" },
-
"violet" => { 100 => "#ede9fe", 200 => "#ddd6fe", 500 => "#8b5cf6", 600 => "#7c3aed", 700 => "#6d28d9", 800 => "#5b21b6", 900 => "#4c1d95" },
-
"amber" => { 100 => "#fef3c7", 200 => "#fde68a", 500 => "#f59e0b", 600 => "#d97706", 700 => "#b45309", 800 => "#92400e", 900 => "#78350f" },
-
"emerald" => { 100 => "#d1fae5", 200 => "#a7f3d0", 500 => "#10b981", 600 => "#059669", 700 => "#047857", 800 => "#065f46", 900 => "#064e3b" },
-
"cyan" => { 100 => "#cffafe", 200 => "#a5f3fc", 500 => "#06b6d4", 600 => "#0891b2", 700 => "#0e7490", 800 => "#155e75", 900 => "#164e63" },
-
"blue" => { 100 => "#dbeafe", 200 => "#bfdbfe", 500 => "#3b82f6", 600 => "#2563eb", 700 => "#1d4ed8", 800 => "#1e40af", 900 => "#1e3a8a" },
-
"green" => { 100 => "#dcfce7", 200 => "#bbf7d0", 500 => "#22c55e", 600 => "#16a34a", 700 => "#15803d", 800 => "#166534", 900 => "#14532d" },
-
"red" => { 100 => "#fee2e2", 200 => "#fecaca", 500 => "#ef4444", 600 => "#dc2626", 700 => "#b91c1c", 800 => "#991b1b", 900 => "#7f1d1d" }
-
}.freeze
-
-
1
def self.resolve(color_name, shade, fallback: nil)
-
34
then: 0
else: 34
return fallback if color_name.blank?
-
-
34
name = color_name.to_s.delete_prefix(":")
-
34
COLORS.dig(name, shade) || fallback
-
end
-
-
1
def self.normalize_color(value, default_name:)
-
12
then: 0
else: 12
return default_name.to_s if value.blank?
-
12
value.to_s.delete_prefix(":")
-
end
-
-
1
def self.hex?(value)
-
34
value.is_a?(String) && value.match?(/\A#(?:[0-9a-fA-F]{3}){1,2}\z/)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module AdminSuite
-
1
module UI
-
1
PanelDefinition = Struct.new(:type, :title, :options, keyword_init: true)
-
1
RowDefinition = Struct.new(:panels, keyword_init: true)
-
-
1
class DashboardDefinition
-
1
attr_reader :rows
-
-
1
def initialize
-
5
@rows = []
-
end
-
end
-
-
# DSL used inside `portal.dashboard do ... end`.
-
1
class DashboardDSL
-
1
def initialize(definition)
-
5
@definition = definition
-
end
-
-
1
def row(&block)
-
18
row = RowDefinition.new(panels: [])
-
18
then: 18
else: 0
RowDSL.new(row).instance_eval(&block) if block_given?
-
18
@definition.rows << row
-
18
row
-
end
-
end
-
-
# DSL used inside `row do ... end`.
-
1
class RowDSL
-
1
def initialize(row)
-
18
@row = row
-
end
-
-
1
def panel(type, title = nil, span: nil, **options, &block)
-
57
then: 57
else: 0
options[:span] = span if span
-
57
then: 0
else: 57
options[:block] = block if block_given?
-
57
@row.panels << PanelDefinition.new(type: type.to_sym, title: title, options: options)
-
end
-
-
1
def stat_panel(title, value = nil, span: nil, **options, &block)
-
35
then: 35
else: 0
then: 0
else: 0
value_proc = value.is_a?(Proc) ? value : (block_given? ? block : nil)
-
35
panel(:stat, title, span: span, **options.merge(value: value_proc || value))
-
end
-
-
1
def health_panel(title, status: nil, metrics: nil, span: nil, **options, &block)
-
4
panel(:health, title, span: span, **options.merge(status: status, metrics: metrics, block: block))
-
end
-
-
1
def chart_panel(title, data: nil, span: nil, **options, &block)
-
6
then: 6
else: 0
then: 0
else: 0
data_proc = data.is_a?(Proc) ? data : (block_given? ? block : nil)
-
6
panel(:chart, title, span: span, **options.merge(data: data_proc || data))
-
end
-
-
1
def cards_panel(title, resources: nil, span: nil, **options, &block)
-
4
panel(:cards, title, span: span, **options.merge(resources: resources, block: block))
-
end
-
-
1
def recent_panel(title, scope: nil, link: nil, span: nil, **options, &block)
-
8
panel(:recent, title, span: span, **options.merge(scope: scope, link: link, block: block))
-
end
-
-
1
def table_panel(title, rows: nil, columns: nil, span: nil, **options, &block)
-
panel(:table, title, span: span, **options.merge(rows: rows, columns: columns, block: block))
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module AdminSuite
-
1
module UI
-
1
module FieldRendererRegistry
-
1
class << self
-
1
def handlers
-
23
@handlers ||= {}
-
end
-
-
1
def register(type, &block)
-
23
handlers[type.to_sym] = block
-
end
-
-
1
def render(type, view:, f:, field:, resource:, field_class:)
-
handler = handlers[type.to_sym]
-
else: 0
then: 0
return nil unless handler
-
-
handler.call(view, f, field, resource, field_class)
-
end
-
end
-
end
-
end
-
end
-
-
# ---- default field renderers ----
-
1
AdminSuite::UI::FieldRendererRegistry.register(:textarea) do |_view, f, field, resource, field_class|
-
f.text_area(field.name, class: field_class, rows: field.rows || 4, placeholder: field.placeholder, readonly: field.readonly)
-
end
-
-
1
AdminSuite::UI::FieldRendererRegistry.register(:url) do |_view, f, field, resource, field_class|
-
f.url_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
-
end
-
-
1
AdminSuite::UI::FieldRendererRegistry.register(:email) do |_view, f, field, resource, field_class|
-
f.email_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
-
end
-
-
1
AdminSuite::UI::FieldRendererRegistry.register(:number) do |_view, f, field, resource, field_class|
-
f.number_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
-
end
-
-
1
AdminSuite::UI::FieldRendererRegistry.register(:toggle) do |view, f, field, resource, _field_class|
-
view.render_toggle_field(f, field, resource)
-
end
-
-
1
AdminSuite::UI::FieldRendererRegistry.register(:label) do |view, _f, field, resource, _field_class|
-
label_value = resource.public_send(field.name) rescue nil
-
view.render_label_badge(label_value, color: field.label_color, size: field.label_size, record: resource)
-
end
-
-
1
AdminSuite::UI::FieldRendererRegistry.register(:select) do |_view, f, field, resource, field_class|
-
then: 0
else: 0
collection = field.collection.is_a?(Proc) ? field.collection.call : field.collection
-
f.select(field.name, collection, { include_blank: true }, class: field_class, disabled: field.readonly)
-
end
-
-
1
AdminSuite::UI::FieldRendererRegistry.register(:searchable_select) do |view, f, field, resource, _field_class|
-
view.render_searchable_select(f, field, resource)
-
end
-
-
1
AdminSuite::UI::FieldRendererRegistry.register(:multi_select) do |view, f, field, resource, _field_class|
-
view.render_multi_select(f, field, resource)
-
end
-
-
1
AdminSuite::UI::FieldRendererRegistry.register(:tags) do |view, f, field, resource, _field_class|
-
view.render_multi_select(f, field, resource)
-
end
-
-
1
AdminSuite::UI::FieldRendererRegistry.register(:image) do |view, f, field, resource, _field_class|
-
view.render_file_upload(f, field, resource)
-
end
-
-
1
AdminSuite::UI::FieldRendererRegistry.register(:attachment) do |view, f, field, resource, _field_class|
-
view.render_file_upload(f, field, resource)
-
end
-
-
1
AdminSuite::UI::FieldRendererRegistry.register(:trix) do |_view, f, field, resource, _field_class|
-
f.rich_text_area(field.name, class: "prose dark:prose-invert max-w-none")
-
end
-
-
1
AdminSuite::UI::FieldRendererRegistry.register(:rich_text) do |_view, f, field, resource, _field_class|
-
f.rich_text_area(field.name, class: "prose dark:prose-invert max-w-none")
-
end
-
-
1
AdminSuite::UI::FieldRendererRegistry.register(:markdown) do |_view, f, field, resource, field_class|
-
f.text_area(field.name, class: "#{field_class} font-mono", rows: field.rows || 12, data: { controller: "admin-suite--markdown-editor" }, placeholder: field.placeholder)
-
end
-
-
1
AdminSuite::UI::FieldRendererRegistry.register(:file) do |_view, f, field, resource, _field_class|
-
f.file_field(field.name, class: "form-input-file", accept: field.accept)
-
end
-
-
1
AdminSuite::UI::FieldRendererRegistry.register(:datetime) do |_view, f, field, resource, field_class|
-
f.datetime_local_field(field.name, class: field_class, readonly: field.readonly)
-
end
-
-
1
AdminSuite::UI::FieldRendererRegistry.register(:date) do |_view, f, field, resource, field_class|
-
f.date_field(field.name, class: field_class, readonly: field.readonly)
-
end
-
-
1
AdminSuite::UI::FieldRendererRegistry.register(:time) do |_view, f, field, resource, field_class|
-
f.time_field(field.name, class: field_class, readonly: field.readonly)
-
end
-
-
1
AdminSuite::UI::FieldRendererRegistry.register(:json) do |view, f, field, resource, _field_class|
-
view.render("admin_suite/shared/json_editor_field", f: f, field: field, resource: resource)
-
end
-
-
1
AdminSuite::UI::FieldRendererRegistry.register(:code) do |view, f, field, resource, _field_class|
-
view.render_code_editor(f, field, resource)
-
end
-
-
1
AdminSuite::UI::FieldRendererRegistry.register(:text) do |_view, f, field, resource, field_class|
-
f.text_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
-
end
-
-
1
AdminSuite::UI::FieldRendererRegistry.register(:string) do |_view, f, field, resource, field_class|
-
f.text_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
-
end
-
# frozen_string_literal: true
-
-
1
require "admin_suite/ui/field_renderer_registry"
-
-
1
module AdminSuite
-
1
module UI
-
# Overrides `render_form_field` to use a registry of field renderers,
-
# while leaving the legacy implementation available via `super`.
-
1
module FormFieldRenderer
-
1
def render_form_field(f, field, resource)
-
else: 0
then: 0
return super unless defined?(AdminSuite::UI::FieldRendererRegistry)
-
-
then: 0
else: 0
return if field.if_condition.present? && !field.if_condition.call(resource)
-
then: 0
else: 0
return if field.unless_condition.present? && field.unless_condition.call(resource)
-
-
capture do
-
concat(content_tag(:div, class: "form-group") do
-
concat(f.label(field.name, class: "form-label") do
-
concat(field.label)
-
then: 0
else: 0
concat(content_tag(:span, " *", class: "text-red-500")) if field.required
-
end)
-
-
field_class = "form-input w-full"
-
then: 0
else: 0
field_class += " border-red-500" if resource.errors[field.name].any?
-
-
field_html =
-
AdminSuite::UI::FieldRendererRegistry.render(
-
field.type || :text,
-
view: self,
-
f: f,
-
field: field,
-
resource: resource,
-
field_class: field_class
-
)
-
-
# If the registry doesn't know how to render, fall back to legacy behavior.
-
then: 0
else: 0
return super if field_html.nil?
-
-
concat(field_html)
-
-
then: 0
else: 0
concat(content_tag(:p, field.help, class: "mt-1 text-sm text-slate-500 dark:text-slate-400")) if field.help.present?
-
then: 0
else: 0
concat(content_tag(:p, resource.errors[field.name].first, class: "mt-1 text-sm text-red-600 dark:text-red-400")) if resource.errors[field.name].any?
-
end)
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module AdminSuite
-
1
module UI
-
1
module ShowFormatterRegistry
-
1
class << self
-
1
def class_handlers
-
11
@class_handlers ||= {}
-
end
-
-
1
def default_handler
-
@default_handler
-
end
-
-
1
def register_class(klass, &block)
-
11
class_handlers[klass] = block
-
end
-
-
1
def register_default(&block)
-
1
@default_handler = block
-
end
-
-
1
def format(value, view:, record:, field_name:)
-
then: 0
else: 0
handler = class_handlers.find { |klass, _| value.is_a?(klass) }&.last
-
handler ||= default_handler
-
else: 0
then: 0
return nil unless handler
-
-
handler.call(value, view, record, field_name)
-
end
-
end
-
end
-
end
-
end
-
-
# ---- default show formatters ----
-
1
AdminSuite::UI::ShowFormatterRegistry.register_class(NilClass) do |_value, view, _record, _field|
-
view.content_tag(:span, "—", class: "text-slate-400")
-
end
-
-
1
AdminSuite::UI::ShowFormatterRegistry.register_class(TrueClass) do |_value, view, _record, _field|
-
view.content_tag(:span, class: "inline-flex items-center gap-1") do
-
view.concat(view.admin_suite_icon("check-circle-2", class: "w-4 h-4 text-green-500"))
-
view.concat(view.content_tag(:span, "Yes", class: "text-green-600 dark:text-green-400 font-medium"))
-
end
-
end
-
-
1
AdminSuite::UI::ShowFormatterRegistry.register_class(FalseClass) do |_value, view, _record, _field|
-
view.content_tag(:span, class: "inline-flex items-center gap-1") do
-
view.concat(view.admin_suite_icon("x-circle", class: "w-4 h-4 text-slate-400"))
-
view.concat(view.content_tag(:span, "No", class: "text-slate-500"))
-
end
-
end
-
-
1
AdminSuite::UI::ShowFormatterRegistry.register_class(Time) do |value, view, _record, _field|
-
view.content_tag(:span, class: "inline-flex items-center gap-2") do
-
view.concat(view.content_tag(:span, value.strftime("%B %d, %Y at %H:%M"), class: "font-medium"))
-
view.concat(view.content_tag(:span, "(#{view.time_ago_in_words(value)} ago)", class: "text-slate-500 dark:text-slate-400 text-xs"))
-
end
-
end
-
-
1
AdminSuite::UI::ShowFormatterRegistry.register_class(DateTime) do |value, view, _record, _field|
-
view.content_tag(:span, class: "inline-flex items-center gap-2") do
-
view.concat(view.content_tag(:span, value.strftime("%B %d, %Y at %H:%M"), class: "font-medium"))
-
view.concat(view.content_tag(:span, "(#{view.time_ago_in_words(value)} ago)", class: "text-slate-500 dark:text-slate-400 text-xs"))
-
end
-
end
-
-
1
AdminSuite::UI::ShowFormatterRegistry.register_class(Date) do |value, _view, _record, _field|
-
value.strftime("%B %d, %Y")
-
end
-
-
1
AdminSuite::UI::ShowFormatterRegistry.register_class(ActiveRecord::Base) do |value, view, _record, _field|
-
then: 0
else: 0
link_text = value.respond_to?(:name) ? value.name : "#{value.class.name} ##{value.id}"
-
view.content_tag(:span, link_text, class: "text-indigo-600 dark:text-indigo-400")
-
end
-
-
1
AdminSuite::UI::ShowFormatterRegistry.register_class(Hash) do |value, view, _record, _field|
-
view.render_json_block(value)
-
end
-
-
1
AdminSuite::UI::ShowFormatterRegistry.register_class(Array) do |value, view, _record, _field|
-
then: 0
if value.empty?
-
else: 0
view.content_tag(:span, "Empty array", class: "text-slate-400 italic")
-
then: 0
elsif value.first.is_a?(Hash)
-
view.render_json_block(value)
-
else: 0
else
-
view.content_tag(:div, class: "flex flex-wrap gap-1") do
-
value.each do |item|
-
view.concat(view.content_tag(:span, item.to_s, class: "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300"))
-
end
-
end
-
end
-
end
-
-
1
AdminSuite::UI::ShowFormatterRegistry.register_class(Integer) do |value, view, _record, _field|
-
view.content_tag(:span, view.number_with_delimiter(value), class: "font-mono")
-
end
-
-
1
AdminSuite::UI::ShowFormatterRegistry.register_class(Float) do |value, view, _record, _field|
-
view.content_tag(:span, view.number_with_delimiter(value), class: "font-mono")
-
end
-
-
1
AdminSuite::UI::ShowFormatterRegistry.register_default do |value, view, _record, field_name|
-
value_str = value.to_s
-
-
then: 0
if value_str.start_with?("{", "[") && value_str.length > 10
-
begin
-
parsed = JSON.parse(value_str)
-
view.render_json_block(parsed)
-
rescue JSON::ParserError
-
view.render_text_block(value_str)
-
else: 0
end
-
then: 0
elsif value_str.include?("\n") || value_str.length > 200
-
view.render_text_block(value_str, view.detect_language(field_name, value_str))
-
else: 0
else
-
value_str
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require "admin_suite/ui/show_formatter_registry"
-
-
1
module AdminSuite
-
1
module UI
-
# Overrides `format_show_value` to use a registry of show value formatters,
-
# while leaving the legacy implementation available via `super`.
-
1
module ShowValueFormatter
-
1
def format_show_value(record, field_name)
-
value = record.public_send(field_name) rescue nil
-
-
then: 0
else: 0
if (field_def = admin_suite_field_definition(field_name))
-
else: 0
case field_def.type
-
when: 0
when :markdown
-
rendered =
-
then: 0
if defined?(::MarkdownRenderer)
-
::MarkdownRenderer.render(value.to_s)
-
else: 0
else
-
simple_format(value.to_s)
-
end
-
return content_tag(:div, rendered, class: "prose dark:prose-invert max-w-none")
-
when: 0
when :json
-
begin
-
parsed =
-
then: 0
if value.is_a?(Hash) || value.is_a?(Array)
-
else: 0
value
-
then: 0
else: 0
elsif value.present?
-
JSON.parse(value.to_s)
-
end
-
then: 0
else: 0
return render_json_block(parsed) if parsed
-
rescue JSON::ParserError
-
# fall through
-
end
-
when: 0
when :label
-
return render_label_badge(value, color: field_def.label_color, size: field_def.label_size, record: record)
-
end
-
end
-
-
# If the field isn't in the form config, fall back to index column config
-
# so show pages can still render labels consistently.
-
then: 0
else: 0
then: 0
else: 0
if respond_to?(:resource_config, true) && (rc = resource_config) && rc.index_config&.columns_list
-
col = rc.index_config.columns_list.find { |c| c.name.to_sym == field_name.to_sym }
-
then: 0
else: 0
then: 0
else: 0
if col&.type == :label
-
then: 0
else: 0
label_value = col.content.is_a?(Proc) ? col.content.call(record) : value
-
return render_label_badge(label_value, color: col.label_color, size: col.label_size, record: record)
-
end
-
end
-
-
then: 0
if value.is_a?(ActiveStorage::Attached::One)
-
else: 0
return render_attachment_preview(value)
-
then: 0
else: 0
elsif value.is_a?(ActiveStorage::Attached::Many)
-
return render_attachments_preview(value)
-
end
-
-
formatted =
-
AdminSuite::UI::ShowFormatterRegistry.format(
-
value,
-
view: self,
-
record: record,
-
field_name: field_name
-
)
-
-
else: 0
then: 0
return formatted unless formatted.nil?
-
-
super
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require "omniauth-oauth2"
-
-
1
module OmniAuth
-
1
module Strategies
-
# TechWright OAuth2 strategy for OmniAuth
-
#
-
# Used for authenticating developers to the internal admin portal.
-
# This is separate from regular user authentication.
-
#
-
# Supports separate URLs for browser (authorize) and server (token) requests,
-
# which is needed for devcontainer setups where localhost from the container
-
# doesn't reach the host machine.
-
#
-
# @example Configuration
-
# provider :techwright,
-
# Rails.application.credentials.dig(:techwright, :client_id),
-
# Rails.application.credentials.dig(:techwright, :client_secret),
-
# scope: "openid email profile",
-
# client_options: {
-
# site: "http://localhost:3003",
-
# token_site: "http://host.docker.internal:3003"
-
# }
-
#
-
1
class Techwright < OmniAuth::Strategies::OAuth2
-
1
option :name, "techwright"
-
-
# Default to production URL, can be overridden via credentials or provider options
-
1
option :client_options, {
-
site: "https://techwright.io",
-
authorize_url: "/oauth/authorize",
-
token_url: "/oauth/token"
-
}
-
-
# Optional separate site for server-side requests (token exchange, userinfo)
-
# Used in devcontainer setups where localhost doesn't work from inside container
-
1
option :token_site, nil
-
-
# Returns the unique identifier for the user
-
#
-
# @return [String] The user's TechWright ID
-
1
uid { raw_info["sub"] }
-
-
# Returns user information from the OAuth response
-
#
-
# @return [Hash] User info including email, name, picture, and verification status
-
1
info do
-
{
-
email: raw_info["email"],
-
name: raw_info["name"],
-
image: raw_info["picture"],
-
email_verified: raw_info["email_verified"]
-
}
-
end
-
-
# Returns additional data from the OAuth response
-
#
-
# @return [Hash] Extra data including the raw userinfo response
-
1
extra do
-
{ raw_info: raw_info }
-
end
-
-
# Override client to use token_site for server-side requests
-
#
-
# @return [OAuth2::Client]
-
1
def client
-
@client ||= begin
-
# Use token_site if provided, otherwise fall back to site
-
server_site = options.token_site || options.client_options[:token_site] || options.client_options[:site]
-
-
::OAuth2::Client.new(
-
options.client_id,
-
options.client_secret,
-
deep_symbolize(options.client_options.merge(site: server_site))
-
)
-
end
-
end
-
-
# Fetches user information from the TechWright userinfo endpoint
-
#
-
# @return [Hash] The parsed userinfo response
-
1
def raw_info
-
@raw_info ||= access_token.get("/oauth/userinfo").parsed
-
end
-
-
# Returns the callback URL for OAuth redirects
-
#
-
# @return [String] The full callback URL
-
1
def callback_url
-
full_host + callback_path
-
end
-
-
# Build the authorize URL using the browser-accessible site
-
#
-
# @param params [Hash] Additional parameters
-
# @return [String] The authorization URL
-
1
def authorize_url(params = {})
-
# Use the original site (browser-accessible) for authorize URL
-
browser_site = options.client_options[:site]
-
authorize_path = options.client_options[:authorize_url] || "/oauth/authorize"
-
-
uri = URI.parse(browser_site)
-
uri.path = authorize_path
-
then: 0
else: 0
uri.query = URI.encode_www_form(params) if params.any?
-
uri.to_s
-
end
-
-
# Override request_phase to use browser-accessible site for redirect
-
1
def request_phase
-
browser_site = options.client_options[:site]
-
authorize_path = options.client_options[:authorize_url] || "/oauth/authorize"
-
-
# Build authorize params
-
authorize_params = {
-
client_id: options.client_id,
-
redirect_uri: callback_url,
-
response_type: "code",
-
scope: options.scope
-
}
-
authorize_params[:state] = session["omniauth.state"] = SecureRandom.hex(24)
-
-
redirect URI.parse(browser_site).merge(authorize_path + "?" + URI.encode_www_form(authorize_params)).to_s
-
end
-
end
-
end
-
end